18 private const TOKEN_SCOPE =
'dbxKi.cms.execute';
19 private const API_VERSION =
'0.1';
24 $this->db =
dbx()->get_system_obj(
'dbxDB');
27 public function handle(
string $route =
'api'): void {
29 $request = $this->request();
30 if ($route ===
'describe') {
31 $request[
'action'] =
'system.describe';
32 } elseif ($route ===
'preview') {
33 $request[
'mode'] =
'preview';
34 } elseif ($route ===
'execute') {
35 $request[
'mode'] =
'execute';
38 $action = strtolower(trim((
string)($request[
'action'] ??
'system.describe')));
39 $mode = strtolower(trim((
string)($request[
'mode'] ??
'preview')));
40 $params = is_array($request[
'params'] ??
null) ? $request[
'params'] : array();
42 if ($action ===
'system.describe') {
43 $this->respond($this->describe());
46 if ($action ===
'bundle.describe') {
47 $bundle =
dbx()->get_include_obj(
'dbxKiBundleService',
'dbxKi');
48 $this->respond($bundle->describeBundle());
51 if ($action ===
'system.health') {
52 $this->respond($this->health());
57 $this->fail(
'unknown_action',
'Unbekannte Aktion.', array(
59 'available_actions' => array_keys(
$catalog),
65 $result = $this->read_action($action, $params);
68 'api_version' => self::API_VERSION,
75 if (!in_array(
$mode, array(
'preview',
'execute'),
true)) {
76 $this->fail(
'invalid_mode',
'Erlaubte Modi sind preview und execute.');
79 $plan = $this->build_plan($action, $params);
80 $planId = $this->plan_id($action,
$plan);
82 if (
$mode ===
'preview') {
85 'api_version' => self::API_VERSION,
88 'will_execute' =>
false,
91 'execute_request' => array(
95 'expected_plan_id' => $planId,
96 'confirm' => (
bool)(
$catalog[$action][
'destructive'] ??
false),
102 $this->authorize_execute($request, (
bool)(
$catalog[$action][
'destructive'] ??
false));
103 $expected = trim((
string)($request[
'expected_plan_id'] ??
''));
105 $this->fail(
'plan_changed',
'Der aktuelle Plan stimmt nicht mehr mit expected_plan_id überein.', array(
107 'current_plan_id' => $planId,
108 'current_plan' =>
$plan,
112 $result = $this->execute_action($action, $params,
$plan);
117 'KI-CMS-Aktion ausgeführt',
118 'uid=' . (
int)
dbx()->
user() .
' plan=' . $planId
121 $this->respond(array(
123 'api_version' => self::API_VERSION,
127 'plan_id' => $planId,
130 }
catch (\Throwable $e) {
131 dbx()->sys_msg(
'error',
'dbxKi',
'api',
'Ausnahme', $e->getMessage());
132 $this->fail(
'exception', $e->getMessage());
136 private function request(): array {
138 $raw = file_get_contents(
'php://input');
139 if (is_string($raw) && trim($raw) !==
'') {
140 $json = json_decode($raw,
true);
141 if (is_array(
$json)) {
146 foreach (array(
'action',
'mode',
'token',
'expected_plan_id',
'confirm') as $key) {
147 if (!array_key_exists($key, $request)) {
148 $value =
dbx()->get_request_var($key,
null);
155 if (!isset($request[
'params']) || !is_array($request[
'params'])) {
156 $params =
dbx()->get_request_var(
'params', array(),
'*');
157 if (is_string($params) && trim($params) !==
'') {
158 $decoded = json_decode($params,
true);
159 $params = is_array($decoded) ? $decoded : array();
161 $request[
'params'] = is_array($params) ? $params : array();
167 private function respond(array $data): void {
171 private function fail(
string $code,
string $message, array $details = array()): void {
172 $this->respond(array(
174 'api_version' => self::API_VERSION,
177 'message' => $message,
178 'details' => $details,
183 private function authorize_execute(array $request,
bool $destructive): void {
184 if ((int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1) !== 1) {
185 $this->fail(
'execute_disabled',
'Automatische Ausführung ist in der Modulkonfiguration deaktiviert.');
187 $token = trim((
string)($request[
'token'] ??
''));
189 $this->fail(
'invalid_token',
'Ungültiges oder abgelaufenes Aktions-Token.');
191 if ($destructive && !$this->bool_value($request[
'confirm'] ??
false)) {
192 $this->fail(
'confirmation_required',
'Diese Aktion löscht Daten. Für automatische Ausführung confirm=true senden.');
196 private function describe(): array {
199 'api_version' => self::API_VERSION,
201 'purpose' =>
'KI-optimierte Bedienung des dbXapp-CMS ohne direkten SQL-Zugriff.',
202 'endpoint' =>
'?dbx_modul=dbxKi&dbx_run1=api',
203 'authentication' => array(
204 'read_and_preview' =>
'Normale dbXapp-Modulberechtigung.',
205 'execute' =>
'Admin-Sitzung plus token.',
207 'token_scope' => self::TOKEN_SCOPE,
210 'method' =>
'GET oder POST; für komplexe Daten POST mit application/json verwenden.',
212 'action' =>
'Eine Aktion aus actions.',
213 'mode' =>
'preview oder execute; bei Leseaktionen wird mode ignoriert.',
214 'params' =>
'Aktionsparameter.',
215 'token' =>
'Nur für execute.',
216 'expected_plan_id' =>
'Optional. Verhindert Ausführung, falls sich der Plan seit preview geändert hat.',
217 'confirm' =>
'Bei Löschaktionen für execute zwingend true.',
219 'automation' => array(
220 'safe' =>
'Erst preview aufrufen und execute_request aus der Antwort unverändert senden.',
221 'direct' =>
'Für vollautomatische Ausführung action, mode=execute, token und params direkt senden.',
222 'rule' =>
'Keine SQL-Befehle erzeugen. Ausschließlich diese Aktionen verwenden.',
225 'page_workflows' => $this->page_workflows(),
226 'languages' => dbxContentLngSync::accessibleLngs(),
227 'actions' => $this->catalog(),
229 'preview_page_create' => array(
230 'action' =>
'page.create',
235 'title' =>
'Neue KI-Seite',
236 'content' =>
'<p>Inhalt</p>',
239 'automatic_page_update' => array(
240 'action' =>
'page.update',
246 'title' =>
'Aktualisierter Titel',
249 'translation' => array(
250 'action' =>
'translation.apply',
254 'source_lng' =>
'de',
255 'target_lng' =>
'en',
257 'translation' => array(
258 'title' =>
'Translated title',
259 'description' =>
'Translated description',
260 'keywords' =>
'translated, keywords',
261 'content' =>
'<p>Translated content</p>',
262 'seo_title' =>
'Translated SEO title',
270 private function page_workflows(): array {
273 'ki_role' =>
'Die KI erzeugt nur JSON-Dateien und optionale Assets. dbxKi importiert, prueft und fuehrt alles aus.',
274 'no_external_tools' =>
'Keine eigenen PHP-, SQL-, Shell-, Python- oder Node-Tools fuer CMS-Aenderungen erzeugen.',
275 'delivery' =>
'antwort.zip mit manifest.json, job.json, optional assets/ und README.md; alternativ job_json im dbxKi-Importformular.',
276 'auto_execute' =>
'Wenn dbxKi nach erfolgreicher Pruefung automatisch ausfuehren soll: manifest.auto_execute = true setzen.',
278 'page_create' => array(
279 'guide_action' =>
'page.create_guide',
281 'Arbeitskontext mit cms.snapshot oder page.create_guide lesen.',
282 'Neue Medien zuerst mit media.create_base64 oder media.create_image_variant als Step anlegen.',
283 'page.create mit lng, folder_id, title, template, content, description, keywords, activ anlegen.',
284 'Inline-Bilder im content immer mit $ref:{media_step}.inline_src und data-cms-media-id setzen.',
285 'Verwendete Inline-/Gallery-/Hero-Medien mit media.assign zuordnen.',
286 'dbxKi importiert job.json, validiert jeden Step und fuehrt den Prozess aus.',
288 'fixed_rules' => array(
289 'folder_id, lng und title aus dem Auftrag nicht eigenmaechtig aendern.',
290 'template nur aus Auftrag/Guide verwenden; bei Root-Seiten nie parent verwenden.',
291 'Kein SQL, keine direkten Dateipfade files/media/... in img src.',
292 'HTML ist erlaubt; Bootstrap-5-Klassen sind erlaubt; kein eigenes JavaScript.',
293 'Hero-Bilder unter img/hero, Gallery-Bilder unter img/gallery, normale Inline-Bilder unter img/images.',
294 'Ein Seitenkopf mit Bild und ueberlagertem Text ist immer ein CMS-Hero: Bild per media.assign slot=hero und hero_image_id/hero_template setzen; Hero-Text vor den dbx:hero-Marker schreiben.',
295 'Niemals einen Hero als Inline-Bild mit position-relative/position-absolute im Content nachbauen.',
298 'page_update' => array(
299 'guide_action' =>
'page.update_guide',
301 'Bestehende Seite mit page.get oder page.update_guide lesen.',
302 'Nur Felder aendern, die im Auftrag/change_fields erlaubt sind.',
303 'Bei content-Aenderung vorhandene data-cms-media-id, dbx_mid-URLs, Links und Modulaufrufe erhalten, ausser der Auftrag fordert Aenderung.',
304 'Bestehendes Hero-Bild ersetzen: page.hero_replace_image. Neues Hero-Bild setzen: page.hero_create_image.',
305 'page.update nur fuer Seitenfelder verwenden; Medien danach bei Bedarf mit media.assign verknuepfen.',
306 'dbxKi importiert job.json, validiert jeden Step und fuehrt den Prozess aus.',
308 'fixed_rules' => array(
309 'id, lng und permalink der Zielseite nicht eigenmaechtig aendern.',
310 'Kein page.delete in KI-Auftraegen.',
311 'Keine vorhandenen Medienpfade manuell umschreiben.',
312 'Wenn content nicht in change_fields steht, content unveraendert lassen.',
313 'Hero-Aenderungen nur ueber page.hero_replace_image/page.hero_create_image oder Hero-Felder plus media.assign slot=hero ausfuehren.',
314 'Keinen Inline-Schein-Hero mit absolut positioniertem Text am Seitenanfang erzeugen.',
320 private function page_create_guide(array $params): array {
321 $lng = $this->language($params[
'lng'] ??
'');
322 $folderId = max(0, (
int)($params[
'folder_id'] ?? $params[
'folder'] ?? 0));
323 $title = $this->clean($params[
'title'] ??
'___TITEL___', 254);
324 $withHero = $this->bool_value($params[
'with_hero'] ??
false);
325 $withGallery = $this->bool_value($params[
'with_gallery'] ??
false);
326 $template = $this->clean($params[
'template'] ?? ($withHero ?
'c-title-hero_header-gallery-body1-footer' :
'c-body1-footer'), 254);
327 if ($folderId === 0 && strtolower(
$template) ===
'parent') {
335 'action' =>
'media.create_base64',
337 'asset_ref' =>
'hero.jpg',
338 'file_name' =>
'hero.jpg',
339 'media_folder' =>
'img/hero',
340 'title' => $title .
' Hero',
348 'action' =>
'media.create_base64',
350 'asset_ref' =>
'gallery-1.jpg',
351 'file_name' =>
'gallery-1.jpg',
352 'media_folder' =>
'img/gallery',
353 'title' => $title .
' Galerie',
360 'action' =>
'page.create',
363 'folder_id' => $folderId,
366 'hero_height' => $withHero ?
'300px' :
'parent',
367 'description' =>
'___SEO_BESCHREIBUNG___',
368 'keywords' =>
'___KEYWORDS___',
370 'content' =>
'___HTML_CONTENT___',
375 'id' =>
'hero_assign',
376 'action' =>
'media.assign',
379 'media_id' =>
'$ref:hero.media_id',
380 'content_id' =>
'$ref:page.page_id',
387 'id' =>
'gallery_assign_1',
388 'action' =>
'media.assign',
391 'media_id' =>
'$ref:gallery_1.media_id',
392 'content_id' =>
'$ref:page.page_id',
399 'workflow' => $this->page_workflows()[
'page_create'],
402 'recipe' =>
'page.create.v1',
404 'auto_execute' =>
true,
406 'job' => array(
'steps' =>
$steps),
407 'content_contract' => $this->content_contract(),
411 private function page_update_guide(array $params): array {
412 $lng = $this->language($params[
'lng'] ??
'');
413 $id = max(0, (
int)($params[
'id'] ?? 0));
414 $heroMode = strtolower(trim((
string)($params[
'hero_mode'] ??
'none')));
415 if (!in_array($heroMode, array(
'none',
'replace',
'create'),
true)) {
418 $fields = $params[
'change_fields'] ?? array(
'content');
420 $fields = array_values(array_filter(array_map(
'trim', explode(
',',
$fields))));
427 if ($heroMode ===
'replace') {
429 'id' =>
'hero_replace',
430 'action' =>
'page.hero_replace_image',
434 'source_file' =>
'assets/hero.jpg',
440 } elseif ($heroMode ===
'create') {
442 'id' =>
'hero_create',
443 'action' =>
'page.hero_create_image',
447 'source_file' =>
'assets/hero.jpg',
448 'file_name' =>
'hero.jpg',
459 if (
$field ===
'')
continue;
460 $patch[
$field] =
$field ===
'content' ?
'___HTML_CONTENT___' :
'___' . strtoupper(
$field) .
'___';
464 'id' =>
'page_update',
465 'action' =>
'page.update',
477 $current = $this->page_get(array(
'lng' =>
$lng,
'id' => $id));
478 }
catch (\Throwable $e) {
479 $current = array(
'error' => $e->getMessage());
484 'workflow' => $this->page_workflows()[
'page_update'],
485 'target' => array(
'lng' =>
$lng,
'id' => $id,
'change_fields' => array_values(
$fields),
'hero_mode' => $heroMode),
486 'current_page' => $current,
488 'title' =>
'Seite ' . $id .
' aktualisieren',
489 'recipe' =>
'page.update.v1',
491 'auto_execute' =>
true,
493 'job' => array(
'steps' =>
$steps),
494 'content_contract' => $this->content_contract(),
498 private function content_contract(): array {
500 'html_allowed' => true,
501 'bootstrap_allowed' => true,
502 'forbidden' => array(
'SQL',
'direkte SQLite-Aenderungen',
'eigene PHP-Tools',
'eigene JavaScript-Logik im Content',
'files/media/... als img src',
'Inline-Schein-Hero mit position-relative/position-absolute'),
503 'inline_media' =>
'Immer inline_src/index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid={id} plus data-cms-media-id verwenden.',
505 'image' =>
'Das Hero-Bild gehoert in die CMS-Hero-Zuordnung (slot=hero und hero_image_id), niemals in content.',
506 'text' =>
'Hero-Text steht vor dem hr-Marker data-dbx-marker="dbx:hero".',
507 'validation' =>
'dbxKi lehnt einen Inline-Bildblock mit absolut ueberlagertem Hero-Text am Seitenanfang ab.',
509 'openwin' =>
'openWin nur ueber class dbx-win und data-dbx="lib=openWin|url=...|title=...|width=...|height=..." verwenden.',
511 'dbx:hero' =>
'Text davor wird Hero-Text.',
512 'dbx:header' =>
'Text danach bis zum naechsten Marker wird Header.',
513 'dbx:footer' =>
'Text danach wird Footer.',
518 private function catalog(): array {
520 'system.health' => array(
522 'description' =>
'Prüft Modul, Benutzer, Sprachen und CMS-Datenzugriff.',
525 'cms.snapshot' => array(
527 'description' =>
'Liefert Ordner, Seiten und Medien in einem begrenzten Arbeitskontext.',
528 'params' => array(
'lng' =>
'Sprachcode',
'folder_id' =>
'Optionaler Ordner',
'limit' =>
'1..200'),
530 'folder.list' => array(
532 'description' =>
'Listet CMS-Ordner.',
533 'params' => array(
'lng' =>
'Sprachcode',
'parent_id' =>
'Optionaler Parent',
'limit' =>
'1..500'),
535 'folder.get' => array(
537 'description' =>
'Liest einen Ordner.',
538 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Ordner-ID'),
540 'folder.create' => array(
542 'description' =>
'Erstellt einen Ordner.',
543 'required' => array(
'name'),
545 'lng' =>
'Sprachcode',
546 'name' =>
'Bezeichnung',
547 'parent_id' =>
'Parent-ID, Standard 0',
548 'group_read' =>
'parent oder kommaseparierte Gruppen',
549 'template' =>
'Content-Template',
550 'hero_*' =>
'Optionale Hero-Einstellungen',
553 'folder.update' => array(
555 'description' =>
'Ändert oder verschiebt einen Ordner.',
556 'required' => array(
'id'),
557 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Ordner-ID',
'patch' =>
'Zu ändernde Felder oder Felder direkt in params'),
559 'folder.delete' => array(
561 'destructive' => true,
562 'description' =>
'Löscht einen leeren Ordner in einer Sprache.',
563 'required' => array(
'id'),
564 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Ordner-ID'),
566 'page.list' => array(
568 'description' =>
'Listet CMS-Seiten.',
569 'params' => array(
'lng' =>
'Sprachcode',
'folder_id' =>
'Optionaler Ordner',
'limit' =>
'1..500'),
573 'description' =>
'Liest eine Seite einschließlich Medienzuordnungen.',
574 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Seiten-ID'),
576 'page.create_guide' => array(
578 'description' =>
'Liefert den verbindlichen KI-Ablauf, Regeln und ein job.json-Skelett fuer das Anlegen einer CMS-Seite.',
579 'params' => array(
'lng' =>
'Sprachcode',
'folder_id' =>
'Zielordner',
'title' =>
'Seitentitel',
'with_hero' =>
'0/1',
'with_gallery' =>
'0/1'),
581 'page.update_guide' => array(
583 'description' =>
'Liefert den verbindlichen KI-Ablauf, Regeln und ein job.json-Skelett fuer das Aendern einer CMS-Seite.',
584 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Seiten-ID',
'change_fields' =>
'Liste erlaubter Felder',
'hero_mode' =>
'none, replace oder create'),
586 'page.create' => array(
588 'description' =>
'Erstellt eine CMS-Seite.',
589 'required' => array(
'title'),
591 'lng' =>
'Sprachcode',
592 'folder_id' =>
'Ordner-ID',
594 'seo_title' =>
'Optionaler SEO-Titel; Standard ist der Seitentitel',
595 'content' =>
'HTML-Inhalt',
596 'description' =>
'Meta-Beschreibung',
597 'keywords' =>
'Meta-Keywords',
598 'permalink' =>
'Optional; wird sonst erzeugt',
599 'activ' =>
'0 oder 1',
600 'template' =>
'Content-Template',
603 'page.update' => array(
605 'description' =>
'Aktualisiert ausgewählte Seitenfelder. Inline-Bilder in content werden automatisch auf CMS-Medien-URLs (dbx_mid) normalisiert.',
606 'required' => array(
'id'),
608 'lng' =>
'Sprachcode',
610 'patch' =>
'Zu ändernde Felder oder Felder direkt in params',
611 'package_product_image' =>
'Optional 1: Paket-Card auf vorhandenes Produktbild (home-package-*) umstellen',
612 'package_media_id' =>
'Optional: Medien-ID statt Permalink-Zuordnung',
613 'package_image_alt' =>
'Optional: alt-Text fuer das Produktbild',
616 'page.hero_replace_image' => array(
618 'description' =>
'Ersetzt nur die bestehende Hero-Bilddatei einer Seite. Medienverknüpfung und Seitenfelder bleiben unverändert.',
619 'required' => array(
'id',
'source_file'),
621 'lng' =>
'Sprachcode',
623 'source_file' =>
'Absolute oder dbxapp-relative neue Bildquelle',
624 'width' =>
'Optional; Standard Breite des bestehenden Hero-Mediums',
625 'height' =>
'Optional; Standard Höhe des bestehenden Hero-Mediums',
626 'fit' =>
'cover oder contain, Standard cover',
627 'quality' =>
'1..100, Standard 82',
630 'page.hero_create_image' => array(
632 'description' =>
'Erstellt ein neues Hero-Bild in files/media/img/hero und setzt es als Hero der Seite.',
633 'required' => array(
'id',
'source_file'),
635 'lng' =>
'Sprachcode',
637 'source_file' =>
'Absolute oder dbxapp-relative Bildquelle',
638 'file_name' =>
'Optionaler Dateiname, Standard aus Permalink',
639 'width' =>
'Zielbreite, Standard 1280',
640 'height' =>
'Zielhöhe, Standard 300',
641 'fit' =>
'cover oder contain, Standard cover',
642 'quality' =>
'1..100, Standard 82',
645 'page.delete' => array(
647 'destructive' => true,
648 'description' =>
'Löscht eine Seite in einer Sprache und deaktiviert ihre Medienzuordnungen.',
649 'required' => array(
'id'),
650 'params' => array(
'lng' =>
'Sprachcode',
'id' =>
'Seiten-ID'),
652 'media.list' => array(
654 'description' =>
'Listet aktive Medien und optional deren Zuordnungen.',
655 'params' => array(
'media_type' =>
'image, video, file oder external_video',
'folder' =>
'Medienordner',
'limit' =>
'1..500'),
657 'media.get' => array(
659 'description' =>
'Liest ein Medium und seine aktiven Verwendungen.',
660 'params' => array(
'id' =>
'Medien-ID'),
662 'module.assets' => array(
664 'description' =>
'Listet vorhandene Modulbilder aus dbx/modules/*/tpl/mod und files/mod fuer Content- und Modul-Visualisierungen.',
665 'params' => array(
'module' =>
'Optionaler Modulname',
'limit' =>
'1..500'),
667 'media.create_base64' => array(
669 'description' =>
'Speichert eine Base64-Datei über dbXapp und registriert sie als Medium. Liefert inline_src/inline_img fuer die Content-Einbindung.',
670 'required' => array(
'file_name',
'data_base64'),
672 'file_name' =>
'Dateiname mit Endung',
673 'data_base64' =>
'Reines Base64 oder Data-URL',
674 'media_folder' =>
'Standard img/images, img/video oder file/ki; Hero immer img/hero, Gallery immer img/gallery',
676 'alt' =>
'Alternativtext',
677 'caption' =>
'Bildunterschrift',
680 'returns' => array(
'id',
'row',
'inline_src',
'inline_img'),
681 'usage' =>
'Im Content immer inline_src oder inline_img verwenden. Niemals files/media/... direkt in img src setzen.',
683 'media.create_image_variant' => array(
685 'description' =>
'Erzeugt aus einer lokalen Bildquelle eine zugeschnittene, skalierte und optional farblich getönte Bildvariante und registriert sie als Medium. Liefert inline_src/inline_img fuer die Content-Einbindung.',
686 'required' => array(
'source_file',
'file_name'),
688 'source_file' =>
'Absolute oder dbxapp-relative Quelldatei',
689 'file_name' =>
'Zieldateiname mit .webp, .jpg, .jpeg oder .png',
690 'width' =>
'Zielbreite, Standard Originalbreite',
691 'height' =>
'Zielhöhe, Standard Originalhöhe',
692 'fit' =>
'cover oder contain, Standard cover',
693 'crop_x/crop_y/crop_width/crop_height' =>
'Optionaler Quell-Ausschnitt in Pixeln vor dem Skalieren',
694 'tint' =>
'Optionale Farbe als #RRGGBB',
695 'tint_strength' =>
'0..1, Standard 0',
696 'quality' =>
'1..100, Standard 82',
697 'media_folder' =>
'Standard img/images; Hero immer img/hero, Gallery immer img/gallery',
699 'alt' =>
'Alternativtext',
700 'caption' =>
'Bildunterschrift',
703 'returns' => array(
'id',
'row',
'inline_src',
'inline_img'),
704 'usage' =>
'Im Content immer inline_src oder inline_img verwenden. Niemals files/media/... direkt in img src setzen.',
706 'media.update' => array(
708 'description' =>
'Ändert Metadaten eines Mediums.',
709 'required' => array(
'id'),
710 'params' => array(
'id' =>
'Medien-ID',
'patch' =>
'title, alt, caption, tags, template'),
712 'media.assign' => array(
714 'description' =>
'Ordnet ein Medium einer Seite oder einem Ordner zu.',
715 'required' => array(
'media_id'),
717 'media_id' =>
'Medien-ID',
718 'content_id' =>
'Seiten-ID',
719 'folder_id' =>
'Ordner-ID',
720 'slot' =>
'hero, gallery, inline, header, teaser oder footer',
721 'template' =>
'Darstellungs-Template',
722 'caption' =>
'Kontextspezifische Bildunterschrift',
723 'settings' =>
'Objekt oder JSON-Text',
726 'media.unassign' => array(
728 'description' =>
'Deaktiviert eine Medienzuordnung.',
729 'required' => array(
'usage_id'),
730 'params' => array(
'usage_id' =>
'ID aus dbxMediaUsage'),
732 'media.delete' => array(
734 'destructive' => true,
735 'description' =>
'Löscht ein unbenutztes Medium einschließlich lokaler Datei.',
736 'required' => array(
'id'),
737 'params' => array(
'id' =>
'Medien-ID'),
739 'translation.preview' => array(
741 'description' =>
'Liefert Quelltext, vorhandenes Ziel und genaue Übersetzungsanweisung.',
742 'required' => array(
'source_lng',
'target_lng',
'source_id'),
743 'params' => array(
'source_lng' =>
'Quellsprache',
'target_lng' =>
'Zielsprache',
'source_id' =>
'Quellseiten-ID'),
745 'translation.apply' => array(
747 'description' =>
'Speichert eine von der KI gelieferte Übersetzung; kein externer Übersetzungsdienst nötig.',
748 'required' => array(
'source_lng',
'target_lng',
'source_id',
'translation'),
750 'source_lng' =>
'Quellsprache',
751 'target_lng' =>
'Zielsprache',
752 'source_id' =>
'Quellseiten-ID',
753 'translation' =>
'Objekt mit title, description, keywords und content; optional seo_title sowie img_alt_1..3 und img_des_1..3',
754 'copy_media' =>
'1 kopiert aktive Medienzuordnungen, Standard 1',
757 'translation.sync_all' => array(
759 'description' =>
'Übersetzt eine komplette CMS-Sprachstruktur aus einer Quellsprache in eine oder mehrere Zielsprachen.',
760 'required' => array(
'source_lng'),
762 'source_lng' =>
'Quellsprache',
763 'target_lngs' =>
'Optional: Array oder kommaseparierte Zielsprachen; Standard alle aktiven Sprachen außer source_lng',
764 'root_folder_id' =>
'Optional: Ordner-Teilbaum; Standard 0 = alle Ordner und Seiten',
765 'update_existing' =>
'1 aktualisiert vorhandene Zielseiten/-ordner, Standard 1',
766 'skip_manual' =>
'1 überspringt Ziel-Datensätze mit lng_sync=manual, Standard 0',
767 'copy_media' =>
'1 kopiert aktive Medienzuordnungen, Standard 1',
768 'replace_media_usage' =>
'1 ersetzt Medienzuordnungen der Zielseite; Standard 0 = nur fehlende ergänzen',
774 private function health(): array {
775 $this->ensure_schema();
778 'api_version' => self::API_VERSION,
779 'user_id' => (
int)
dbx()->
user(),
781 'execute_enabled' => (
int)
dbx()->get_config(
'dbxKi',
'allow_execute', 1),
782 'languages' => dbxContentLngSync::accessibleLngs(),
783 'master_language' => dbxContentLngSync::masterLng(),
784 'content_count' => $this->db->count(dbxContentLng::ddContent($this->language(
''))),
785 'folder_count' => $this->db->count(dbxContentLng::ddFolder($this->language(
''))),
786 'media_count' => $this->db->count(
'dbxMedia',
'active = 1'),
790 private function read_action(
string $action, array $params) {
791 $this->ensure_schema();
794 return $this->snapshot($params);
796 return $this->folder_list($params);
798 return $this->folder_get($params);
800 return $this->page_list($params);
802 return $this->page_get($params);
803 case 'page.create_guide':
804 return $this->page_create_guide($params);
805 case 'page.update_guide':
806 return $this->page_update_guide($params);
808 return $this->media_list($params);
810 return $this->media_get($params);
811 case 'module.assets':
812 return $this->module_assets($params);
813 case 'translation.preview':
814 return $this->translation_preview($params);
816 throw new \RuntimeException(
'Leseaktion nicht implementiert: ' . $action);
819 private function build_plan(
string $action, array $params): array {
820 $this->ensure_schema();
822 case 'folder.create':
823 return $this->plan_folder_create($params);
824 case 'folder.update':
825 return $this->plan_folder_update($params);
826 case 'folder.delete':
827 return $this->plan_folder_delete($params);
829 return $this->plan_page_create($params);
831 return $this->plan_page_update($params);
832 case 'page.hero_replace_image':
833 return $this->plan_page_hero_replace_image($params);
834 case 'page.hero_create_image':
835 return $this->plan_page_hero_create_image($params);
837 return $this->plan_page_delete($params);
838 case 'media.create_base64':
839 return $this->plan_media_create($params);
840 case 'media.create_image_variant':
841 return $this->plan_media_create_image_variant($params);
843 return $this->plan_media_update($params);
845 return $this->plan_media_assign($params);
846 case 'media.unassign':
847 return $this->plan_media_unassign($params);
849 return $this->plan_media_delete($params);
850 case 'translation.apply':
851 return $this->plan_translation_apply($params);
852 case 'translation.sync_all':
853 return $this->plan_translation_sync_all($params);
855 throw new \RuntimeException(
'Planung nicht implementiert: ' . $action);
858 private function execute_action(
string $action, array $params, array
$plan) {
860 case 'folder.create':
861 return $this->execute_folder_create(
$plan);
862 case 'folder.update':
863 return $this->execute_folder_update(
$plan);
864 case 'folder.delete':
865 return $this->execute_folder_delete(
$plan);
867 return $this->execute_page_create(
$plan);
869 return $this->execute_page_update(
$plan);
870 case 'page.hero_replace_image':
871 return $this->execute_page_hero_replace_image(
$plan);
872 case 'page.hero_create_image':
873 return $this->execute_page_hero_create_image(
$plan);
875 return $this->execute_page_delete(
$plan);
876 case 'media.create_base64':
877 return $this->execute_media_create($params,
$plan);
878 case 'media.create_image_variant':
879 return $this->execute_media_create_image_variant(
$plan);
881 return $this->execute_media_update(
$plan);
883 return $this->execute_media_assign(
$plan);
884 case 'media.unassign':
885 return $this->execute_media_unassign(
$plan);
887 return $this->execute_media_delete(
$plan);
888 case 'translation.apply':
889 return $this->execute_translation_apply($params,
$plan);
890 case 'translation.sync_all':
891 return $this->execute_translation_sync_all(
$plan);
893 throw new \RuntimeException(
'Ausführung nicht implementiert: ' . $action);
896 private function ensure_schema(): void {
897 if (!is_object($this->db)) {
898 throw new \RuntimeException(
'dbxDB ist nicht verfügbar.');
900 dbxContentLngSync::ensureSchema($this->db);
903 private function language(
$value): string {
906 $lng = dbxContentLng::current();
908 if (!in_array(
$lng, dbxContentLngSync::accessibleLngs(),
true)) {
909 throw new \InvalidArgumentException(
'Nicht unterstützte Sprache: ' .
$lng);
914 private function id(array $params,
string $key =
'id'): int {
915 $id = (int)($params[$key] ?? 0);
917 throw new \InvalidArgumentException($key .
' muss größer als 0 sein.');
922 private function limit(array $params,
int $default = 100,
int $max = 500): int {
923 return max(1, min($max, (int)($params[
'limit'] ?? $default)));
926 private function bool_value(
$value): bool {
928 return in_array(strtolower(trim((
string)
$value)), array(
'1',
'true',
'yes',
'ja',
'on'),
true);
931 private function clean(
$value,
int $max = 0): string {
939 private function plan_id(
string $action, array
$plan): string {
940 return hash(
'sha256', $action .
'|' . json_encode(
$plan, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
943 private function snapshot(array $params): array {
945 'language' => $this->language($params[
'lng'] ??
''),
946 'folders' => $this->folder_list($params),
947 'pages' => $this->page_list($params),
948 'media' => $this->media_list(array(
'limit' => $this->limit($params, 100, 200))),
949 'module_assets' => $this->module_assets(array(
'limit' => 200)),
953 private function folder_list(array $params): array {
954 $lng = $this->language($params[
'lng'] ??
'');
956 if (array_key_exists(
'parent_id', $params)) {
957 $where =
'parent_id = ' . max(0, (
int)$params[
'parent_id']);
959 $rows = $this->db->select(dbxContentLng::ddFolder(
$lng), $where,
'*',
'sorter,id',
'ASC',
'', $this->limit($params), 0, 1);
960 return array(
'lng' =>
$lng,
'rows' => is_array(
$rows) ?
$rows : array());
963 private function folder_get(array $params): array {
964 $lng = $this->language($params[
'lng'] ??
'');
965 $id = $this->id($params);
966 $row = $this->db->select1(dbxContentLng::ddFolder(
$lng), $id);
967 if (!is_array($row))
throw new \RuntimeException(
'Ordner nicht gefunden.');
968 return array(
'lng' =>
$lng,
'row' => $row);
971 private function page_list(array $params): array {
972 $lng = $this->language($params[
'lng'] ??
'');
974 if (array_key_exists(
'folder_id', $params)) {
975 $where =
'folder = ' . max(0, (
int)$params[
'folder_id']);
977 $rows = $this->db->select(dbxContentLng::ddContent(
$lng), $where,
'*',
'folder,sorter,id',
'ASC',
'', $this->limit($params), 0, 1);
978 return array(
'lng' =>
$lng,
'rows' => is_array(
$rows) ?
$rows : array());
981 private function page_get(array $params): array {
982 $lng = $this->language($params[
'lng'] ??
'');
983 $id = $this->id($params);
984 $row = $this->db->select1(dbxContentLng::ddContent(
$lng), $id);
985 if (!is_array($row))
throw new \RuntimeException(
'Seite nicht gefunden.');
986 $usage = $this->db->select(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $id .
' AND active = 1',
$lng),
'*',
'slot,sorter,id',
'ASC');
987 $hint = $this->package_page_hint($row);
991 'media_usage' => is_array($usage) ? $usage : array(),
992 'package_hint' => $hint,
996 private function media_list(array $params): array {
997 $rows = $this->db->select(
'dbxMedia',
'active = 1',
'*',
'id',
'DESC',
'', $this->limit($params), 0, 1);
998 $type = strtolower(trim((
string)($params[
'media_type'] ??
'')));
999 $folder = trim((
string)($params[
'folder'] ??
''));
1000 $rows = array_values(array_filter(is_array(
$rows) ?
$rows : array(),
static function($row) use ($type, $folder) {
1001 if ($type !==
'' && strtolower((
string)($row[
'media_type'] ??
'')) !== $type)
return false;
1002 if ($folder !==
'' && (
string)($row[
'media_folder'] ??
'') !== $folder)
return false;
1005 return array(
'rows' =>
$rows);
1008 private function media_get(array $params): array {
1009 $id = $this->id($params);
1010 $row = $this->db->select1(
'dbxMedia', $id);
1011 if (!is_array($row))
throw new \RuntimeException(
'Medium nicht gefunden.');
1012 $usage = $this->db->select(
'dbxMediaUsage',
'media_id = ' . $id .
' AND active = 1',
'*',
'id',
'ASC');
1013 return array(
'row' => $row,
'usage' => is_array($usage) ? $usage : array());
1016 private function module_assets(array $params): array {
1018 $moduleFilter = strtolower(trim((
string)($params[
'module'] ??
'')));
1019 $limit = $this->limit($params, 200, 500);
1024 $path = str_replace(
'\\',
'/',
$file);
1025 if (!is_file(
$file)) {
1028 if (!preg_match(
'/\.(svg|png|jpe?g|webp|gif)$/i', $path)) {
1032 $rel = str_starts_with($path,
$base) ? substr($path, strlen(
$base)) : $path;
1033 $name = basename($path);
1036 if (preg_match(
'#dbx/modules/([^/]+)/tpl/mod/([^/]+)\.[^.]+$#', $rel, $m)) {
1038 $stem = (string)$m[2];
1040 $stem = preg_replace(
'/\.[^.]+$/',
'', $name);
1041 if (preg_match(
'/^([A-Za-z0-9_]+)__(.+)$/', (
string)$stem, $m)) {
1043 $action = (string)$m[2];
1046 if ($action ===
'' &&
$module !==
'') {
1048 $stem = preg_replace(
'/\.[^.]+$/',
'', $name);
1049 $action = str_starts_with((
string)$stem, $prefix) ? substr((
string)$stem, strlen($prefix)) : (string)$stem;
1051 if ($moduleFilter !==
'' && strtolower(
$module) !== $moduleFilter) {
1055 if (isset($seen[$key])) {
1061 'action' => $action,
1066 'bytes' => filesize(
$file),
1071 $add(
$file,
'module_tpl_mod');
1072 if (count(
$rows) >= $limit) {
1076 if (count(
$rows) < $limit) {
1078 $add(
$file,
'files_mod');
1079 if (count(
$rows) >= $limit) {
1087 'usage' =>
'Im Content als <img src=\"{src}\" ...> verwenden. Vorhandene Modulbilder bevorzugen, wenn Module visuell dargestellt werden.',
1091 private function translation_preview(array $params): array {
1092 $sourceLng = $this->language($params[
'source_lng'] ??
'');
1093 $targetLng = $this->language($params[
'target_lng'] ??
'');
1094 if ($sourceLng === $targetLng)
throw new \InvalidArgumentException(
'Quell- und Zielsprache müssen verschieden sein.');
1095 $sourceId = $this->id($params,
'source_id');
1096 $sourceDd = dbxContentLng::ddContent($sourceLng);
1097 $source = $this->db->select1($sourceDd, $sourceId);
1098 if (!is_array(
$source))
throw new \RuntimeException(
'Quellseite nicht gefunden.');
1099 $uid = trim((
string)(
$source[
'lng_uid'] ??
''));
1100 $targetId = $uid !==
''
1101 ? dbxContentLngSync::resolveIdByUid($this->db, dbxContentLng::ddContent($targetLng), $uid, $targetLng)
1103 $target = $targetId > 0 ? $this->db->select1(dbxContentLng::ddContent($targetLng), $targetId) :
null;
1105 'source_lng' => $sourceLng,
1106 'target_lng' => $targetLng,
1109 'source_uid_missing' => $uid ===
'',
1110 'instruction' => array(
1111 'translate_fields' => array(
1112 'title',
'description',
'keywords',
'content',
'seo_title',
1113 'img_alt_1',
'img_alt_2',
'img_alt_3',
1114 'img_des_1',
'img_des_2',
'img_des_3'
1116 'preserve' =>
'HTML-Struktur, Links, data-cms-media-id, Platzhalter, IDs, CSS-Klassen und technische Attribute unverändert lassen.',
1117 'do_not_translate' =>
'Dateipfade, URLs, Modulnamen, Template-Namen, Shortcodes und Code.',
1118 'quality' =>
'Natürlich, fachlich korrekt und zur Zielsprache passend übersetzen. Keine zusätzlichen Aussagen erfinden.',
1119 'next_action' =>
'translation.apply mit translation-Objekt aufrufen.',
1124 private function plan_folder_create(array $params): array {
1125 $lng = $this->language($params[
'lng'] ??
'');
1126 $name = $this->clean($params[
'name'] ??
'', 120);
1127 if ($name ===
'')
throw new \InvalidArgumentException(
'name ist erforderlich.');
1128 $parent = max(0, (
int)($params[
'parent_id'] ?? 0));
1129 if ($parent > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder(
$lng), $parent))) {
1130 throw new \RuntimeException(
'Parent-Ordner nicht gefunden.');
1133 'operation' =>
'insert',
1134 'entity' =>
'folder',
1136 'data' => $this->folder_data($params, $parent, $name),
1140 private function plan_folder_update(array $params): array {
1141 $lng = $this->language($params[
'lng'] ??
'');
1142 $id = $this->id($params);
1143 $dd = dbxContentLng::ddFolder(
$lng);
1144 $before = $this->db->select1(
$dd, $id);
1145 if (!is_array($before))
throw new \RuntimeException(
'Ordner nicht gefunden.');
1146 $patch = $this->patch($params);
1147 $parent = array_key_exists(
'parent_id', $patch) ? max(0, (
int)$patch[
'parent_id']) : (int)($before[
'parent_id'] ?? 0);
1148 if ($parent === $id || $this->folder_descendant(
$dd, $parent, $id)) {
1149 throw new \InvalidArgumentException(
'Ungültiger Parent: Schleife im Ordnerbaum.');
1151 if ($parent > 0 && !is_array($this->db->select1(
$dd, $parent)))
throw new \RuntimeException(
'Parent-Ordner nicht gefunden.');
1152 $allowed = array(
'name',
'parent_id',
'group_read',
'template',
'hero_template',
'hero_image_id',
'hero_margin_top',
'hero_height',
'hero_variant',
'hero_sticky',
'hero_scroll_layer',
'sorter');
1153 $data = $this->whitelist($patch,
$allowed);
1154 if (isset($data[
'name'])) $data[
'name'] = $this->clean($data[
'name'], 120);
1155 if (!$data)
throw new \InvalidArgumentException(
'Keine änderbaren Felder übergeben.');
1156 return array(
'operation' =>
'update',
'entity' =>
'folder',
'lng' =>
$lng,
'id' => $id,
'before' => $before,
'changes' => $data);
1159 private function plan_folder_delete(array $params): array {
1160 $lng = $this->language($params[
'lng'] ??
'');
1161 $id = $this->id($params);
1162 $row = $this->db->select1(dbxContentLng::ddFolder(
$lng), $id);
1163 if (!is_array($row))
throw new \RuntimeException(
'Ordner nicht gefunden.');
1164 $check = dbxContentLngSync::folderDeletable($this->db,
$lng, $id);
1165 if ((
int)(
$check[
'deletable'] ?? 0) !== 1) {
1166 throw new \RuntimeException((
string)(
$check[
'reason'] ??
'Ordner ist nicht löschbar.'));
1168 return array(
'operation' =>
'delete',
'entity' =>
'folder',
'lng' =>
$lng,
'id' => $id,
'before' => $row);
1171 private function plan_page_create(array $params): array {
1172 $lng = $this->language($params[
'lng'] ??
'');
1173 $title = $this->clean($params[
'title'] ??
'', 254);
1174 if ($title ===
'')
throw new \InvalidArgumentException(
'title ist erforderlich.');
1175 $folder = max(0, (
int)($params[
'folder_id'] ?? $params[
'folder'] ?? 0));
1176 if ($folder > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder(
$lng), $folder))) {
1177 throw new \RuntimeException(
'Zielordner nicht gefunden.');
1179 $data = $this->page_data($params,
$lng, $folder, $title);
1180 $this->assert_no_fake_inline_hero((
string)($data[
'content'] ??
''));
1182 'operation' =>
'insert',
1189 private function plan_page_update(array $params): array {
1190 $lng = $this->language($params[
'lng'] ??
'');
1191 $id = $this->id($params);
1192 $dd = dbxContentLng::ddContent(
$lng);
1193 $before = $this->db->select1(
$dd, $id);
1194 if (!is_array($before))
throw new \RuntimeException(
'Seite nicht gefunden.');
1195 $patch = $this->patch($params);
1196 if (array_key_exists(
'folder_id', $patch) && !array_key_exists(
'folder', $patch)) {
1197 $patch[
'folder'] = $patch[
'folder_id'];
1199 $packageProductImage = $this->bool_value($patch[
'package_product_image'] ??
false);
1200 $packageMediaId = max(0, (
int)($patch[
'package_media_id'] ?? 0));
1201 $packageImageAlt = $this->clean($patch[
'package_image_alt'] ??
'', 254);
1202 unset($patch[
'package_product_image'], $patch[
'package_media_id'], $patch[
'package_image_alt']);
1204 'activ',
'folder',
'title',
'menu_title',
'seo_title',
'permalink',
'description',
'keywords',
'group_read',
'template',
'content',
'sorter',
1205 'hero_template',
'hero_image_id',
'hero_margin_top',
'hero_height',
'hero_variant',
'hero_sticky',
1206 'hero_scroll_layer',
'gallery_template',
'gallery_visible_count',
'gallery_image_size',
1207 'gallery_lightbox_width',
'gallery_overflow',
'gallery_click_behavior'
1209 $data = $this->whitelist($patch,
$allowed);
1210 if (isset($data[
'title'])) $data[
'title'] = $this->clean($data[
'title'], 254);
1211 if (isset($data[
'menu_title'])) $data[
'menu_title'] = $this->clean($data[
'menu_title'], 96);
1212 if (isset($data[
'seo_title'])) $data[
'seo_title'] = $this->clean($data[
'seo_title'], 254);
1213 if (array_key_exists(
'permalink', $data)) {
1214 $data[
'permalink'] = trim($this->clean($data[
'permalink'], 254));
1215 if (!dbxContent_permalink::isValid($data[
'permalink'])) {
1216 throw new \InvalidArgumentException(
'permalink darf nur Kleinbuchstaben, Zahlen und einzelne Bindestriche enthalten.');
1218 if (dbxContent_permalink::exists($this->db,
$dd, $data[
'permalink'], $id)) {
1219 throw new \InvalidArgumentException(
'permalink wird bereits von einer anderen Seite verwendet.');
1222 if (isset($data[
'folder'])) {
1223 $data[
'folder'] = max(0, (
int)$data[
'folder']);
1224 if ($data[
'folder'] > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder(
$lng), $data[
'folder']))) {
1225 throw new \RuntimeException(
'Zielordner nicht gefunden.');
1228 if (!$data && !$packageProductImage && $packageMediaId <= 0) {
1229 throw new \InvalidArgumentException(
'Keine änderbaren Felder übergeben.');
1231 if (array_key_exists(
'content', $data)) {
1232 $data[
'content'] = $this->normalize_content_inline_media_urls((
string)$data[
'content']);
1234 $packageMediaApplied = 0;
1235 if ($packageProductImage || $packageMediaId > 0) {
1238 : $this->package_media_id_for_permalink((
string)($before[
'permalink'] ??
''));
1240 throw new \RuntimeException(
'Kein Paket-Produktbild fuer diese Seite gefunden. package_media_id angeben oder home-package-* Medium anlegen.');
1242 $content = array_key_exists(
'content', $data)
1243 ? (string)$data[
'content']
1244 : (string)($before[
'content'] ??
'');
1245 $data[
'content'] = $this->normalize_content_inline_media_urls(
1250 if (array_key_exists(
'content', $data)) {
1251 $this->assert_no_fake_inline_hero((
string)$data[
'content']);
1253 $plan = array(
'operation' =>
'update',
'entity' =>
'page',
'lng' =>
$lng,
'id' => $id,
'before' => $before,
'changes' => $data);
1254 if ($packageMediaApplied > 0) {
1255 $plan[
'package_media_id_applied'] = $packageMediaApplied;
1260 private function plan_page_hero_replace_image(array $params): array {
1261 $lng = $this->language($params[
'lng'] ??
'');
1262 $id = $this->id($params);
1263 $hero = $this->hero_media_for_page(
$lng, $id);
1265 $source = $this->source_image_plan($params);
1267 if (
$target ===
'')
throw new \RuntimeException(
'Hero-Medium ist keine lokale Datei.');
1269 $width = max(1, (
int)($params[
'width'] ??
$media[
'width'] ?? 0));
1270 $height = max(1, (
int)($params[
'height'] ??
$media[
'height'] ?? 0));
1271 if ($width <= 1 || $height <= 1) {
1275 $mime = (string)(
$media[
'mime'] ??
'');
1276 if (!in_array($mime, array(
'image/jpeg',
'image/png',
'image/webp'),
true)) {
1277 $mime = $this->mime_from_file_name((
string)(
$media[
'file_name'] ??
'hero.webp'));
1281 'operation' =>
'replace_page_hero_file',
1282 'entity' =>
'page_hero',
1285 'page' =>
$hero[
'page'],
1287 'usage' =>
$hero[
'usage'],
1291 'height' => $height,
1292 'fit' => $this->image_fit($params[
'fit'] ??
'cover'),
1293 'quality' => $this->image_quality($params[
'quality'] ?? 82),
1298 private function plan_page_hero_create_image(array $params): array {
1299 $lng = $this->language($params[
'lng'] ??
'');
1300 $id = $this->id($params);
1301 $dd = dbxContentLng::ddContent(
$lng);
1302 $page = $this->db->select1(
$dd, $id);
1303 if (!is_array(
$page))
throw new \RuntimeException(
'Seite nicht gefunden.');
1305 $permalink = trim((
string)(
$page[
'permalink'] ??
''));
1306 $baseName = $permalink !==
'' ? $permalink : (
'page-' . $id);
1307 $fileName = $this->safe_file_name($params[
'file_name'] ?? ($baseName .
'-hero.webp'));
1308 if ($fileName ===
'') $fileName =
'page-' . $id .
'-hero.webp';
1310 $variant = $this->plan_media_create_image_variant(array_merge($params, array(
1311 'file_name' => $fileName,
1312 'width' => max(1, (
int)($params[
'width'] ?? 1280)),
1313 'height' => max(1, (
int)($params[
'height'] ?? 300)),
1314 'fit' => $params[
'fit'] ??
'cover',
1315 'quality' => $params[
'quality'] ?? 82,
1316 'media_folder' =>
'img/hero',
1317 'title' => $params[
'title'] ?? (
'Hero ' . (
$page[
'title'] ?? $fileName)),
1318 'alt' => $params[
'alt'] ?? (
string)(
$page[
'title'] ??
''),
1322 'operation' =>
'create_page_hero_media',
1323 'entity' =>
'page_hero',
1327 'media_plan' => $variant,
1331 private function plan_page_delete(array $params): array {
1332 $lng = $this->language($params[
'lng'] ??
'');
1333 $id = $this->id($params);
1334 $row = $this->db->select1(dbxContentLng::ddContent(
$lng), $id);
1335 if (!is_array($row))
throw new \RuntimeException(
'Seite nicht gefunden.');
1336 $usage = $this->db->count(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $id .
' AND active = 1',
$lng));
1337 return array(
'operation' =>
'delete',
'entity' =>
'page',
'lng' =>
$lng,
'id' => $id,
'before' => $row,
'media_usage_to_deactivate' => $usage);
1340 private function plan_media_create(array $params): array {
1341 $name = $this->safe_file_name($params[
'file_name'] ??
'');
1342 $raw = (string)($params[
'data_base64'] ??
'');
1343 if ($name ===
'' || trim($raw) ===
'')
throw new \InvalidArgumentException(
'file_name und data_base64 sind erforderlich.');
1344 $decoded = $this->decode_base64($raw);
1345 $max = max(1024, (
int)
dbx()->get_config(
'dbxKi',
'max_base64_bytes', 10485760));
1346 if (strlen($decoded) > $max)
throw new \InvalidArgumentException(
'Datei überschreitet das konfigurierte Größenlimit.');
1347 $mime = $this->detect_mime($decoded, $name);
1348 $allowed = array(
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'video/mp4',
'video/webm',
'video/quicktime',
'application/pdf',
'text/plain');
1349 if (!in_array($mime,
$allowed,
true))
throw new \InvalidArgumentException(
'Nicht unterstützter MIME-Typ: ' . $mime);
1350 $type = strpos($mime,
'image/') === 0 ?
'image' : (strpos($mime,
'video/') === 0 ?
'video' :
'file');
1351 $defaultFolder = $type ===
'image' ?
'img/images' : ($type ===
'video' ?
'img/video' :
'file/ki');
1352 $folder = $this->media_folder($params[
'media_folder'] ?? $defaultFolder, $type);
1354 'operation' =>
'create_file_and_insert',
1355 'entity' =>
'media',
1356 'file_name' => $name,
1357 'bytes' => strlen($decoded),
1358 'sha256' => hash(
'sha256', $decoded),
1360 'media_type' => $type,
1361 'media_folder' => $folder,
1362 'metadata' => array(
1363 'title' => $this->clean($params[
'title'] ?? pathinfo($name, PATHINFO_FILENAME), 160),
1364 'alt' => $this->clean($params[
'alt'] ??
'', 254),
1365 'caption' => $this->clean($params[
'caption'] ??
''),
1366 'tags' => $this->clean($params[
'tags'] ??
'', 254),
1371 private function plan_media_create_image_variant(array $params): array {
1372 if (!extension_loaded(
'gd')) {
1373 throw new \RuntimeException(
'GD ist erforderlich, um Bildvarianten zu erzeugen.');
1376 $source = $this->resolve_local_file((
string)($params[
'source_file'] ??
''));
1378 throw new \InvalidArgumentException(
'source_file ist nicht lesbar.');
1381 $name = $this->safe_file_name($params[
'file_name'] ??
'');
1382 if ($name ===
'')
throw new \InvalidArgumentException(
'file_name ist erforderlich.');
1383 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
1384 $mimeMap = array(
'jpg' =>
'image/jpeg',
'jpeg' =>
'image/jpeg',
'png' =>
'image/png',
'webp' =>
'image/webp');
1385 if (!isset($mimeMap[$ext])) {
1386 throw new \InvalidArgumentException(
'file_name muss .webp, .jpg, .jpeg oder .png verwenden.');
1389 $info = @getimagesize(
$source);
1390 if (!is_array($info) || empty($info[0]) || empty($info[1])) {
1391 throw new \InvalidArgumentException(
'source_file ist kein lesbares Bild.');
1393 $sourceMime = (string)($info[
'mime'] ??
'');
1394 if (!in_array($sourceMime, array(
'image/jpeg',
'image/png',
'image/webp',
'image/gif'),
true)) {
1395 throw new \InvalidArgumentException(
'Nicht unterstützter Quellbildtyp: ' . $sourceMime);
1398 $sourceWidth = (int)$info[0];
1399 $sourceHeight = (int)$info[1];
1400 $crop = $this->image_crop_rect($params, $sourceWidth, $sourceHeight);
1401 $width = max(1, (
int)($params[
'width'] ?? $sourceWidth));
1402 $height = max(1, (
int)($params[
'height'] ?? $sourceHeight));
1403 $fit = strtolower(trim((
string)($params[
'fit'] ??
'cover')));
1404 if (!in_array($fit, array(
'cover',
'contain'),
true)) $fit =
'cover';
1405 $quality = min(100, max(1, (
int)($params[
'quality'] ?? 82)));
1406 $tint = $this->normalize_hex_color((
string)($params[
'tint'] ??
''));
1407 $tintStrength = max(0.0, min(1.0, (
float)($params[
'tint_strength'] ?? 0)));
1408 $folder = $this->media_folder($params[
'media_folder'] ??
'img/images',
'image');
1411 'operation' =>
'create_image_variant_and_insert',
1412 'entity' =>
'media',
1414 'source_sha256' => hash_file(
'sha256',
$source),
1415 'source_mime' => $sourceMime,
1416 'source_width' => $sourceWidth,
1417 'source_height' => $sourceHeight,
1419 'file_name' => $name,
1420 'mime' => $mimeMap[$ext],
1421 'media_type' =>
'image',
1422 'media_folder' => $folder,
1424 'height' => $height,
1426 'quality' => $quality,
1428 'tint_strength' => $tintStrength,
1429 'metadata' => array(
1430 'title' => $this->clean($params[
'title'] ?? pathinfo($name, PATHINFO_FILENAME), 160),
1431 'alt' => $this->clean($params[
'alt'] ??
'', 254),
1432 'caption' => $this->clean($params[
'caption'] ??
''),
1433 'tags' => $this->clean($params[
'tags'] ??
'', 254),
1438 private function plan_media_update(array $params): array {
1439 $id = $this->id($params);
1440 $before = $this->db->select1(
'dbxMedia', $id);
1441 if (!is_array($before) || (
int)($before[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Medium nicht gefunden.');
1442 $data = $this->whitelist($this->patch($params), array(
'title',
'alt',
'caption',
'tags',
'template'));
1443 if (!$data)
throw new \InvalidArgumentException(
'Keine änderbaren Metadaten übergeben.');
1444 return array(
'operation' =>
'update',
'entity' =>
'media',
'id' => $id,
'before' => $before,
'changes' => $data);
1447 private function plan_media_assign(array $params): array {
1448 $mediaId = $this->id($params,
'media_id');
1450 if (!is_array(
$media) || (
int)(
$media[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Medium nicht gefunden.');
1451 $contentId = max(0, (
int)($params[
'content_id'] ?? 0));
1452 $folderId = max(0, (
int)($params[
'folder_id'] ?? 0));
1453 if (($contentId > 0) === ($folderId > 0))
throw new \InvalidArgumentException(
'Genau content_id oder folder_id muss gesetzt sein.');
1454 $lng = $this->language($params[
'lng'] ??
'');
1455 if ($contentId > 0 && !is_array($this->db->select1(dbxContentLng::ddContent(
$lng), $contentId)))
throw new \RuntimeException(
'Seite nicht gefunden.');
1456 if ($folderId > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder(
$lng), $folderId)))
throw new \RuntimeException(
'Ordner nicht gefunden.');
1457 $slot = $this->slot($params[
'slot'] ??
'gallery');
1459 'operation' =>
'insert',
1460 'entity' =>
'media_usage',
1466 'content_id' => $contentId,
1467 'folder_id' => $folderId,
1469 'template' => $this->clean($params[
'template'] ??
$media[
'template'] ??
'', 80),
1470 'caption' => $this->clean($params[
'caption'] ??
''),
1471 'settings' => is_array($params[
'settings'] ??
null)
1472 ? json_encode($params[
'settings'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
1473 : $this->clean($params[
'settings'] ??
''),
1478 private function plan_media_unassign(array $params): array {
1479 $id = $this->id($params,
'usage_id');
1480 $row = $this->db->select1(
'dbxMediaUsage', $id);
1481 if (!is_array($row) || (
int)($row[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Aktive Medienzuordnung nicht gefunden.');
1482 return array(
'operation' =>
'update',
'entity' =>
'media_usage',
'id' => $id,
'before' => $row,
'changes' => array(
'active' => 0));
1485 private function plan_media_delete(array $params): array {
1486 $id = $this->id($params);
1487 $data = $this->media_get(array(
'id' => $id));
1488 if (count($data[
'usage']))
throw new \RuntimeException(
'Medium wird noch verwendet. Zuerst media.unassign ausführen.');
1489 return array(
'operation' =>
'delete',
'entity' =>
'media',
'id' => $id,
'before' => $data[
'row']);
1492 private function plan_translation_apply(array $params): array {
1493 $preview = $this->translation_preview($params);
1494 $translation = is_array($params[
'translation'] ??
null) ? $params[
'translation'] : array();
1495 foreach (array(
'title',
'description',
'keywords',
'content') as
$field) {
1496 if (!array_key_exists(
$field, $translation))
throw new \InvalidArgumentException(
'translation.' .
$field .
' fehlt.');
1498 $translation = $this->whitelist($translation, array(
1499 'title',
'description',
'keywords',
'content',
'seo_title',
1500 'img_alt_1',
'img_alt_2',
'img_alt_3',
1501 'img_des_1',
'img_des_2',
'img_des_3'
1503 $translation[
'title'] = $this->clean($translation[
'title'], 254);
1504 $translation[
'description'] = $this->clean($translation[
'description'], 254);
1505 $translation[
'keywords'] = $this->clean($translation[
'keywords'], 254);
1506 foreach (array(
'seo_title',
'img_alt_1',
'img_alt_2',
'img_alt_3') as
$field) {
1507 if (array_key_exists(
$field, $translation)) {
1508 $translation[
$field] = $this->clean($translation[
$field], 254);
1511 foreach (array(
'img_des_1',
'img_des_2',
'img_des_3') as
$field) {
1512 if (array_key_exists(
$field, $translation)) {
1513 $translation[
$field] = $this->clean($translation[
$field]);
1516 if ($translation[
'title'] ===
'')
throw new \InvalidArgumentException(
'Übersetzter Titel darf nicht leer sein.');
1517 $translation[
'content'] = $this->normalize_content_inline_media_urls((
string)$translation[
'content']);
1519 'operation' => is_array($preview[
'target']) ?
'update' :
'insert',
1520 'entity' =>
'translation',
1521 'source_lng' => $preview[
'source_lng'],
1522 'target_lng' => $preview[
'target_lng'],
1523 'source' => $preview[
'source'],
1524 'target' => $preview[
'target'],
1525 'translation' => $translation,
1526 'copy_media' => !array_key_exists(
'copy_media', $params) || $this->bool_value($params[
'copy_media']),
1530 private function plan_translation_sync_all(array $params): array {
1531 $sourceLng = $this->language($params[
'source_lng'] ??
'');
1532 $targetLngs = $this->target_languages($params, $sourceLng);
1533 if (!count($targetLngs)) {
1534 throw new \InvalidArgumentException(
'Keine Zielsprachen gefunden.');
1537 $rootFolderId = max(0, (
int)($params[
'root_folder_id'] ?? $params[
'folder_id'] ?? 0));
1538 if ($rootFolderId > 0 && !is_array($this->db->select1(dbxContentLng::ddFolder($sourceLng), $rootFolderId))) {
1539 throw new \RuntimeException(
'Quellordner nicht gefunden.');
1542 $folderIds = $this->collect_folder_ids_for_lng($sourceLng, $rootFolderId);
1543 $pageIds = $this->collect_page_ids_for_lng($sourceLng, $rootFolderId, $folderIds);
1546 'operation' =>
'translation_sync_all',
1547 'entity' =>
'content_language',
1548 'source_lng' => $sourceLng,
1549 'target_lngs' => $targetLngs,
1550 'root_folder_id' => $rootFolderId,
1551 'update_existing' => !array_key_exists(
'update_existing', $params) || $this->bool_value($params[
'update_existing']),
1552 'skip_manual' => array_key_exists(
'skip_manual', $params) && $this->bool_value($params[
'skip_manual']),
1553 'copy_media' => !array_key_exists(
'copy_media', $params) || $this->bool_value($params[
'copy_media']),
1554 'replace_media_usage' => array_key_exists(
'replace_media_usage', $params) && $this->bool_value($params[
'replace_media_usage']),
1555 'provider' => dbxContentTranslate::provider(),
1557 'folders' => count($folderIds),
1558 'pages' => count($pageIds),
1559 'target_languages' => count($targetLngs),
1561 'source_ids' => array(
1562 'folders' => $folderIds,
1563 'pages' => $pageIds,
1568 private function execute_folder_create(array
$plan): array {
1569 $dd = dbxContentLng::ddFolder(
$plan[
'lng']);
1570 $data =
$plan[
'data'];
1571 $data[
'sorter'] = $this->next_sorter(
$dd,
'parent_id', (
int)$data[
'parent_id']);
1572 $data += $this->lng_fields(
'f',
$plan[
'lng']);
1573 if ($this->db->insert(
$dd, $data) !== 1)
throw new \RuntimeException(
'Ordner konnte nicht erstellt werden.');
1574 $id = $this->db->get_insert_id();
1575 $this->invalidate_folder($id);
1576 return array(
'id' => $id,
'row' => $this->db->select1(
$dd, $id));
1579 private function execute_folder_update(array
$plan): array {
1580 $dd = dbxContentLng::ddFolder(
$plan[
'lng']);
1581 $data =
$plan[
'changes'];
1582 $data = $this->advance_revision(
$dd,
$plan[
'id'], $data,
$plan[
'lng']);
1583 if ($this->db->update(
$dd, $data,
$plan[
'id']) !== 1)
throw new \RuntimeException(
'Ordner konnte nicht aktualisiert werden.');
1584 $this->invalidate_folder(
$plan[
'id']);
1585 return array(
'id' =>
$plan[
'id'],
'row' => $this->db->select1(
$dd,
$plan[
'id']));
1588 private function execute_folder_delete(array
$plan): array {
1589 $dd = dbxContentLng::ddFolder(
$plan[
'lng']);
1590 if ($this->db->delete(
$dd,
$plan[
'id']) !== 1)
throw new \RuntimeException(
'Ordner konnte nicht gelöscht werden.');
1591 $this->invalidate_folder(
$plan[
'id']);
1592 return array(
'deleted' =>
true,
'id' =>
$plan[
'id'],
'lng' =>
$plan[
'lng']);
1595 private function execute_page_create(array
$plan): array {
1596 $dd = dbxContentLng::ddContent(
$plan[
'lng']);
1597 $data =
$plan[
'data'];
1598 if (trim((
string)($data[
'sorter'] ??
'')) ===
'') {
1599 $data[
'sorter'] = $this->next_sorter(
$dd,
'folder', (
int)$data[
'folder']);
1601 $data += $this->lng_fields(
'p',
$plan[
'lng']);
1602 if ($this->db->insert(
$dd, $data) !== 1)
throw new \RuntimeException(
'Seite konnte nicht erstellt werden.');
1603 $id = $this->db->get_insert_id();
1604 $this->invalidate_page($id,
$plan[
'lng'], $data);
1605 return array(
'id' => $id,
'row' => $this->db->select1(
$dd, $id));
1608 private function execute_page_update(array
$plan): array {
1609 $dd = dbxContentLng::ddContent(
$plan[
'lng']);
1611 if ($this->db->update(
$dd, $data,
$plan[
'id']) !== 1)
throw new \RuntimeException(
'Seite konnte nicht aktualisiert werden.');
1616 $row = $this->db->select1(
$dd,
$plan[
'id']);
1617 $this->invalidate_page(
$plan[
'id'],
$plan[
'lng'], $row);
1626 private function execute_page_hero_replace_image(array
$plan): array {
1632 'size' => (
int)@filesize(
$target),
1633 'width' => (
int)
$plan[
'width'],
1634 'height' => (
int)
$plan[
'height'],
1635 'mime' => (
string)
$plan[
'mime'],
1637 if (
$mediaId <= 0)
throw new \RuntimeException(
'Hero-Medium konnte nicht aktualisiert werden.');
1638 $this->db->update(
'dbxMedia', $data,
$mediaId);
1639 $this->invalidate_media_references(
$mediaId);
1640 $this->invalidate_page((
int)
$plan[
'id'], (
string)
$plan[
'lng'],
$plan[
'page']);
1642 'id' => (
int)
$plan[
'id'],
1644 'file' => str_replace(
'\\',
'/',
$target),
1649 private function execute_page_hero_create_image(array
$plan): array {
1650 $media = $this->execute_media_create_image_variant(
$plan[
'media_plan']);
1652 if (
$mediaId <= 0)
throw new \RuntimeException(
'Hero-Medium konnte nicht erstellt werden.');
1657 'content_id' => (
int)
$plan[
'id'],
1659 'content_lng' => dbxContentMediaUsageScope::language((
string)
$plan[
'lng']),
1665 $where = dbxContentMediaUsageScope::withLanguage(
'content_id = ' . (
int)
$plan[
'id'] .
" AND slot = 'hero' AND active = 1", (
string)
$plan[
'lng']);
1666 $this->db->update(
'dbxMediaUsage', array(
'active' => 0), $where, 0, 1, 1, 1);
1667 $data[
'sorter'] = $this->next_usage_sorter((
int)
$plan[
'id'], 0,
'hero', (
string)
$plan[
'lng']);
1668 if ($this->db->insert(
'dbxMediaUsage', $data) !== 1) {
1669 throw new \RuntimeException(
'Hero-Medienzuordnung konnte nicht erstellt werden.');
1671 $usageId = $this->db->get_insert_id();
1672 $this->sync_hero_setting((
string)
$plan[
'lng'], $data);
1673 $this->invalidate_usage($data);
1674 $row = $this->db->select1(dbxContentLng::ddContent((
string)
$plan[
'lng']), (
int)
$plan[
'id']);
1676 'id' => (
int)
$plan[
'id'],
1678 'usage_id' => $usageId,
1680 'media' =>
$media[
'row'] ?? array(),
1684 private function execute_page_delete(array
$plan): array {
1685 $dd = dbxContentLng::ddContent(
$plan[
'lng']);
1686 if ($this->db->delete(
$dd,
$plan[
'id']) !== 1)
throw new \RuntimeException(
'Seite konnte nicht gelöscht werden.');
1687 $this->db->update(
'dbxMediaUsage', array(
'active' => 0), dbxContentMediaUsageScope::withLanguage(
'content_id = ' . (
int)
$plan[
'id'] .
' AND active = 1', (
string)
$plan[
'lng']), 0, 1, 1, 1);
1688 dbxContentPageCache::invalidateContent(
$plan[
'id']);
1689 dbxContentPageCache::invalidateAllMenus();
1690 dbxContentPermalinkIndex::removeByCid(
$plan[
'id'],
$plan[
'lng']);
1691 return array(
'deleted' =>
true,
'id' =>
$plan[
'id'],
'lng' =>
$plan[
'lng']);
1694 private function execute_media_create(array $params, array
$plan): array {
1695 $bytes = $this->decode_base64((string)$params[
'data_base64']);
1696 if (!hash_equals((
string)
$plan[
'sha256'], hash(
'sha256', $bytes))) {
1697 throw new \RuntimeException(
'Der Medieninhalt stimmt nicht mit dem geprüften Plan überein.');
1701 if (!is_dir(
$dir) && !mkdir(
$dir, 0777,
true) && !is_dir(
$dir))
throw new \RuntimeException(
'Medienordner konnte nicht erstellt werden.');
1702 $name = $this->unique_name(
$dir,
$plan[
'file_name']);
1703 $file = rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR . $name;
1704 if (file_put_contents(
$file, $bytes) ===
false)
throw new \RuntimeException(
'Mediendatei konnte nicht geschrieben werden.');
1705 $relative =
'media/' . trim(str_replace(
'\\',
'/',
$plan[
'media_folder']),
'/') .
'/' . $name;
1708 $size = @getimagesize(
$file);
1709 if (is_array($size)) {
1710 $width = (int)($size[0] ?? 0);
1711 $height = (int)($size[1] ?? 0);
1713 $data = array_merge(
$plan[
'metadata'], array(
1715 'file_name' => $name,
1716 'file_path' => $relative,
1717 'mime' =>
$plan[
'mime'],
1718 'size' => strlen($bytes),
1720 'height' => $height,
1721 'media_type' =>
$plan[
'media_type'],
1722 'storage_type' =>
'local',
1723 'media_folder' =>
$plan[
'media_folder'],
1725 if ($this->db->insert(
'dbxMedia', $data) !== 1) {
1727 throw new \RuntimeException(
'Medium konnte nicht registriert werden.');
1729 $id = $this->db->get_insert_id();
1730 return array_merge(array(
'id' => $id,
'row' => $this->db->select1(
'dbxMedia', $id)), $this->media_inline_payload($id));
1733 private function execute_media_create_image_variant(array
$plan): array {
1736 throw new \RuntimeException(
'Quellbild ist nicht lesbar.');
1738 if (!hash_equals((
string)(
$plan[
'source_sha256'] ??
''), hash_file(
'sha256',
$source))) {
1739 throw new \RuntimeException(
'Das Quellbild stimmt nicht mehr mit dem geprüften Plan überein.');
1742 $src = $this->gd_load_image(
$source, (
string)
$plan[
'source_mime']);
1743 $width = max(1, (
int)
$plan[
'width']);
1744 $height = max(1, (
int)
$plan[
'height']);
1745 $dst = imagecreatetruecolor($width, $height);
1746 imagealphablending($dst,
false);
1747 imagesavealpha($dst,
true);
1748 $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
1749 imagefilledrectangle($dst, 0, 0, $width, $height, $transparent);
1751 $sourceWidth = imagesx($src);
1752 $sourceHeight = imagesy($src);
1755 $crop = is_array(
$plan[
'crop'] ??
null) ?
$plan[
'crop'] : array();
1757 $sourceX = max(0, min((
int)($crop[
'x'] ?? 0), $sourceWidth - 1));
1758 $sourceY = max(0, min((
int)($crop[
'y'] ?? 0), $sourceHeight - 1));
1759 $sourceWidth = max(1, min((
int)($crop[
'width'] ?? $sourceWidth), imagesx($src) - $sourceX));
1760 $sourceHeight = max(1, min((
int)($crop[
'height'] ?? $sourceHeight), imagesy($src) - $sourceY));
1762 $fit = (string)(
$plan[
'fit'] ??
'cover');
1763 if ($fit ===
'contain') {
1764 $scale = min($width / $sourceWidth, $height / $sourceHeight);
1765 $copyWidth = max(1, (
int)round($sourceWidth * $scale));
1766 $copyHeight = max(1, (
int)round($sourceHeight * $scale));
1767 $dstX = (int)floor(($width - $copyWidth) / 2);
1768 $dstY = (int)floor(($height - $copyHeight) / 2);
1769 imagecopyresampled($dst, $src, $dstX, $dstY, $sourceX, $sourceY, $copyWidth, $copyHeight, $sourceWidth, $sourceHeight);
1771 $sourceRatio = $sourceWidth / $sourceHeight;
1772 $targetRatio = $width / $height;
1773 if ($sourceRatio > $targetRatio) {
1774 $cropHeight = $sourceHeight;
1775 $cropWidth = (int)round($sourceHeight * $targetRatio);
1776 $srcX = $sourceX + (int)floor(($sourceWidth - $cropWidth) / 2);
1779 $cropWidth = $sourceWidth;
1780 $cropHeight = (int)round($sourceWidth / $targetRatio);
1782 $srcY = $sourceY + (int)floor(($sourceHeight - $cropHeight) / 2);
1784 imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropWidth, $cropHeight);
1788 $this->gd_apply_tint($dst, (
string)(
$plan[
'tint'] ??
''), (
float)(
$plan[
'tint_strength'] ?? 0));
1792 if (!is_dir(
$dir) && !mkdir(
$dir, 0777,
true) && !is_dir(
$dir))
throw new \RuntimeException(
'Medienordner konnte nicht erstellt werden.');
1793 $name = $this->unique_name(
$dir,
$plan[
'file_name']);
1794 $file = rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR . $name;
1795 $this->gd_save_image($dst,
$file, (
string)
$plan[
'mime'], (
int)
$plan[
'quality']);
1798 $relative =
'media/' . trim(str_replace(
'\\',
'/',
$plan[
'media_folder']),
'/') .
'/' . $name;
1799 $data = array_merge(
$plan[
'metadata'], array(
1801 'file_name' => $name,
1802 'file_path' => $relative,
1803 'mime' =>
$plan[
'mime'],
1804 'size' => (
int)@filesize(
$file),
1806 'height' => $height,
1807 'media_type' =>
'image',
1808 'storage_type' =>
'local',
1809 'media_folder' =>
$plan[
'media_folder'],
1811 if ($this->db->insert(
'dbxMedia', $data) !== 1) {
1813 throw new \RuntimeException(
'Medium konnte nicht registriert werden.');
1815 $id = $this->db->get_insert_id();
1816 return array_merge(array(
'id' => $id,
'row' => $this->db->select1(
'dbxMedia', $id)), $this->media_inline_payload($id));
1819 private function execute_media_update(array
$plan): array {
1820 if ($this->db->update(
'dbxMedia',
$plan[
'changes'],
$plan[
'id']) !== 1) throw new \RuntimeException(
'Medium konnte nicht aktualisiert werden.');
1821 $this->invalidate_media_references((
int)
$plan[
'id']);
1822 return array(
'id' =>
$plan[
'id'],
'row' => $this->db->select1(
'dbxMedia',
$plan[
'id']));
1825 private function execute_media_assign(array
$plan): array {
1826 $data =
$plan[
'data'];
1827 $data[
'content_lng'] = dbxContentMediaUsageScope::language((
string)(
$plan[
'lng'] ??
''));
1828 if ($data[
'slot'] ===
'hero') {
1829 $where = $data[
'content_id'] > 0
1830 ?
'content_id = ' . (int)$data[
'content_id']
1831 :
'folder_id = ' . (int)$data[
'folder_id'];
1832 $this->db->update(
'dbxMediaUsage', array(
'active' => 0), dbxContentMediaUsageScope::withLanguage($where .
" AND slot = 'hero' AND active = 1", $data[
'content_lng']), 0, 1, 1, 1);
1834 $data[
'sorter'] = $this->next_usage_sorter($data[
'content_id'], $data[
'folder_id'], $data[
'slot'], $data[
'content_lng']);
1835 if ($this->db->insert(
'dbxMediaUsage', $data) !== 1)
throw new \RuntimeException(
'Medienzuordnung konnte nicht erstellt werden.');
1836 $id = $this->db->get_insert_id();
1837 if ($data[
'slot'] ===
'hero') {
1838 $this->sync_hero_setting((
string)(
$plan[
'lng'] ??
''), $data);
1840 $this->invalidate_usage($data);
1841 return array(
'usage_id' => $id,
'row' => $this->db->select1(
'dbxMediaUsage', $id));
1844 private function sync_hero_setting(
string $lng, array $usage): void {
1845 $mediaId = (int)($usage[
'media_id'] ?? 0);
1846 $contentId = (int)($usage[
'content_id'] ?? 0);
1847 $folderId = (int)($usage[
'folder_id'] ?? 0);
1852 $lng = dbxContentLng::current();
1855 if ($contentId > 0) {
1856 $dd = dbxContentLng::ddContent(
$lng);
1857 $page = $this->db->select1(
$dd, $contentId);
1858 if (!is_array(
$page)) {
1861 $patch = array(
'hero_image_id' => (
string)
$mediaId);
1862 $heroTemplate = trim((
string)(
$page[
'hero_template'] ??
''));
1863 if ($heroTemplate ===
'' || $heroTemplate ===
'parent') {
1864 $patch[
'hero_template'] =
'image-hero';
1866 if ($this->db->update(
$dd, $patch, $contentId) !== 1) {
1869 $row = $this->db->select1(
$dd, $contentId);
1870 if (is_array($row)) {
1871 $this->invalidate_page($contentId,
$lng, $row);
1876 if ($folderId > 0) {
1877 $dd = dbxContentLng::ddFolder(
$lng);
1878 $folder = $this->db->select1(
$dd, $folderId);
1879 if (!is_array($folder)) {
1882 $patch = array(
'hero_image_id' => (
string)
$mediaId);
1883 $heroTemplate = trim((
string)($folder[
'hero_template'] ??
''));
1884 if ($heroTemplate ===
'' || $heroTemplate ===
'parent') {
1885 $patch[
'hero_template'] =
'image-hero';
1887 if ($this->db->update(
$dd, $patch, $folderId) === 1) {
1888 $this->invalidate_folder($folderId);
1893 private function execute_media_unassign(array
$plan): array {
1894 if ($this->db->update(
'dbxMediaUsage', array(
'active' => 0),
$plan[
'id']) !== 1) throw new \RuntimeException(
'Medienzuordnung konnte nicht entfernt werden.');
1895 $this->invalidate_usage(
$plan[
'before']);
1896 return array(
'unassigned' =>
true,
'usage_id' =>
$plan[
'id']);
1899 private function execute_media_delete(array
$plan): array {
1900 require_once dirname(__DIR__, 2) .
'/dbxContent_admin/include/dbxContent_cms.class.php';
1901 $cms = new \dbx\dbxContent_admin\dbxContent_cms();
1903 if ((
int)(
$result[
'ok'] ?? 0) !== 1) {
1904 throw new \RuntimeException(implode(
' ', is_array(
$result[
'errors'] ??
null) ?
$result[
'errors'] : array(
'Medium konnte nicht gelöscht werden.')));
1909 private function execute_translation_apply(array $params, array
$plan): array {
1911 $targetLng =
$plan[
'target_lng'];
1912 $targetDd = dbxContentLng::ddContent($targetLng);
1913 $sourceUid = trim((
string)(
$source[
'lng_uid'] ??
''));
1914 if ($sourceUid ===
'') {
1915 $sourceUid = dbxContentLngSync::ensureRecordUid(
1917 dbxContentLng::ddContent(
$plan[
'source_lng']),
1922 $targetFolder = dbxContentLngSync::ensureFolderIdInLng($this->db, (
int)(
$source[
'folder'] ?? 0), $targetLng);
1923 $data = $this->copy_page_structure(
$source);
1924 $data = array_merge($data,
$plan[
'translation']);
1925 $data[
'folder'] = $targetFolder;
1926 $data[
'permalink'] = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($targetLng), $targetFolder, $data[
'title']);
1927 $data[
'lng_uid'] = $sourceUid;
1928 $data[
'lng_sync'] =
'manual';
1929 $data[
'lng_rev'] = max(1, (
int)(
$plan[
'target'][
'lng_rev'] ?? 0) + 1);
1930 $data[
'lng_synced_rev'] = (int)(
$source[
'lng_rev'] ?? 1);
1932 $targetId = (int)(
$plan[
'target'][
'id'] ?? 0);
1933 if ($targetId > 0) {
1934 if ($this->db->update($targetDd, $data, $targetId) !== 1)
throw new \RuntimeException(
'Übersetzung konnte nicht aktualisiert werden.');
1936 if ($this->db->insert($targetDd, $data) !== 1)
throw new \RuntimeException(
'Übersetzung konnte nicht erstellt werden.');
1937 $targetId = $this->db->get_insert_id();
1941 if (
$plan[
'copy_media']) {
1944 array(
'active' => 0),
1945 dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $targetId .
' AND active = 1', $targetLng),
1951 $mediaCopied = $this->copy_media_usage((
int)
$source[
'id'], $targetId, $targetFolder, (
string)
$plan[
'source_lng'], $targetLng);
1953 $row = $this->db->select1($targetDd, $targetId);
1954 $this->invalidate_page($targetId, $targetLng, $row);
1955 return array(
'id' => $targetId,
'lng' => $targetLng,
'row' => $row,
'media_copied' => $mediaCopied);
1958 private function execute_translation_sync_all(array
$plan): array {
1959 $sourceLng = (string)(
$plan[
'source_lng'] ??
'');
1960 $targetLngs = is_array(
$plan[
'target_lngs'] ??
null) ?
$plan[
'target_lngs'] : array();
1961 $updateExisting = (bool)(
$plan[
'update_existing'] ??
true);
1962 $skipManual = (bool)(
$plan[
'skip_manual'] ??
false);
1963 $copyMedia = (bool)(
$plan[
'copy_media'] ??
true);
1964 $replaceMediaUsage = (bool)(
$plan[
'replace_media_usage'] ??
false);
1965 $sourceIds = is_array(
$plan[
'source_ids'] ??
null) ?
$plan[
'source_ids'] : array();
1966 $folderIds = is_array($sourceIds[
'folders'] ??
null) ? array_map(
'intval', $sourceIds[
'folders']) : array();
1967 $pageIds = is_array($sourceIds[
'pages'] ??
null) ? array_map(
'intval', $sourceIds[
'pages']) : array();
1969 dbxContentTranslate::clearWarnings();
1972 'source_lng' => $sourceLng,
1973 'target_lngs' => $targetLngs,
1974 'provider' => dbxContentTranslate::provider(),
1975 'folders' => array(
'created' => array(),
'updated' => array(),
'skipped' => array()),
1976 'pages' => array(
'created' => array(),
'updated' => array(),
'skipped' => array()),
1977 'media_copied' => 0,
1978 'errors' => array(),
1979 'warnings' => array(),
1982 foreach ($targetLngs as $targetLng) {
1983 $targetLng = $this->language($targetLng);
1984 foreach ($folderIds as $folderId) {
1986 $item = $this->sync_translate_folder($sourceLng, $targetLng, $folderId, $updateExisting, $skipManual);
1987 $bucket = (string)($item[
'status'] ??
'skipped');
1988 $result[
'folders'][$bucket ===
'created' ?
'created' : ($bucket ===
'updated' ?
'updated' :
'skipped')][] = $item;
1989 }
catch (\Throwable $e) {
1990 $result[
'errors'][] =
'Ordner #' . $folderId .
' nach ' . strtoupper($targetLng) .
': ' . $e->getMessage();
1994 foreach ($pageIds as
$pageId) {
1996 $item = $this->sync_translate_page($sourceLng, $targetLng,
$pageId, $updateExisting, $skipManual, $copyMedia, $replaceMediaUsage);
1997 $bucket = (string)($item[
'status'] ??
'skipped');
1998 $result[
'pages'][$bucket ===
'created' ?
'created' : ($bucket ===
'updated' ?
'updated' :
'skipped')][] = $item;
1999 $result[
'media_copied'] += (int)($item[
'media_copied'] ?? 0);
2000 }
catch (\Throwable $e) {
2001 $result[
'errors'][] =
'Seite #' .
$pageId .
' nach ' . strtoupper($targetLng) .
': ' . $e->getMessage();
2006 $result[
'warnings'] = dbxContentTranslate::warnings();
2010 private function sync_translate_folder(
string $sourceLng,
string $targetLng,
int $sourceId,
bool $updateExisting,
bool $skipManual): array {
2011 $sourceDd = dbxContentLng::ddFolder($sourceLng);
2012 $targetDd = dbxContentLng::ddFolder($targetLng);
2013 $source = $this->db->select1($sourceDd, $sourceId);
2015 throw new \RuntimeException(
'Quellordner nicht gefunden.');
2018 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceId,
'f');
2020 throw new \RuntimeException(
'Sprach-ID konnte nicht erzeugt werden.');
2023 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2024 $target = $targetId > 0 ? $this->db->select1($targetDd, $targetId) :
null;
2025 if (is_array(
$target) && !$updateExisting) {
2026 return array(
'status' =>
'skipped',
'reason' =>
'exists',
'entity' =>
'folder',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
2028 if (is_array(
$target) && $skipManual && strtolower(trim((
string)(
$target[
'lng_sync'] ??
''))) ===
'manual') {
2029 return array(
'status' =>
'skipped',
'reason' =>
'manual',
'entity' =>
'folder',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
2032 $name = dbxContentTranslate::translate((
string)(
$source[
'name'] ??
''), $sourceLng, $targetLng,
'folder_name');
2033 if ($name ===
'' && trim((
string)(
$source[
'name'] ??
'')) !==
'') {
2034 $name = (string)
$source[
'name'];
2040 $data = $this->copy_folder_structure(
$source);
2041 $data[
'name'] = $this->clean($name, 120);
2042 $data[
'parent_id'] = $this->target_folder_id_from_source_parent($sourceLng, $targetLng, (
int)(
$source[
'parent_id'] ?? 0));
2043 $data[
'lng_uid'] = $uid;
2044 $data[
'lng_sync'] =
'auto';
2045 $data[
'lng_rev'] = is_array(
$target) ? max(1, (
int)(
$target[
'lng_rev'] ?? 0) + 1) : 0;
2046 $data[
'lng_synced_rev'] = max(1, (
int)(
$source[
'lng_rev'] ?? 1));
2048 if ($targetId > 0) {
2049 if ($this->db->update($targetDd, $data, $targetId) !== 1) {
2050 throw new \RuntimeException(
'Zielordner konnte nicht aktualisiert werden.');
2052 $status =
'updated';
2054 if ($this->db->insert($targetDd, $data) !== 1) {
2055 throw new \RuntimeException(
'Zielordner konnte nicht erstellt werden.');
2057 $targetId = (int)$this->db->get_insert_id();
2058 $status =
'created';
2061 $this->invalidate_folder($targetId);
2062 return array(
'status' => $status,
'entity' =>
'folder',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId,
'name' => $data[
'name']);
2065 private function sync_translate_page(
string $sourceLng,
string $targetLng,
int $sourceId,
bool $updateExisting,
bool $skipManual,
bool $copyMedia,
bool $replaceMediaUsage): array {
2066 $sourceDd = dbxContentLng::ddContent($sourceLng);
2067 $targetDd = dbxContentLng::ddContent($targetLng);
2068 $source = $this->db->select1($sourceDd, $sourceId);
2070 throw new \RuntimeException(
'Quellseite nicht gefunden.');
2073 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceId,
'p');
2075 throw new \RuntimeException(
'Sprach-ID konnte nicht erzeugt werden.');
2078 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2079 $target = $targetId > 0 ? $this->db->select1($targetDd, $targetId) :
null;
2080 if (is_array(
$target) && !$updateExisting) {
2081 return array(
'status' =>
'skipped',
'reason' =>
'exists',
'entity' =>
'page',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
2083 if (is_array(
$target) && $skipManual && strtolower(trim((
string)(
$target[
'lng_sync'] ??
''))) ===
'manual') {
2084 return array(
'status' =>
'skipped',
'reason' =>
'manual',
'entity' =>
'page',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId);
2087 $title = dbxContentTranslate::translate((
string)(
$source[
'title'] ??
''), $sourceLng, $targetLng,
'title');
2088 if ($title ===
'' && trim((
string)(
$source[
'title'] ??
'')) !==
'') {
2089 $title = (string)
$source[
'title'];
2091 if ($title ===
'') {
2092 throw new \RuntimeException(
'Übersetzter Titel ist leer.');
2095 $targetFolder = $this->target_folder_id_from_source_parent($sourceLng, $targetLng, (
int)(
$source[
'folder'] ?? 0));
2096 if ((
int)(
$source[
'folder'] ?? 0) > 0 && $targetFolder <= 0) {
2097 throw new \RuntimeException(
'Zielordner konnte nicht aufgelöst werden.');
2100 $data = $this->copy_page_structure(
$source);
2101 $data[
'folder'] = $targetFolder;
2102 $data[
'title'] = $this->clean($title, 254);
2103 $data[
'description'] = $this->clean(dbxContentTranslate::translate((
string)(
$source[
'description'] ??
''), $sourceLng, $targetLng,
'description'), 254);
2104 $data[
'keywords'] = $this->clean(dbxContentTranslate::translate((
string)(
$source[
'keywords'] ??
''), $sourceLng, $targetLng,
'keywords'), 254);
2105 $data[
'content'] = $this->normalize_content_inline_media_urls(dbxContentTranslate::translate((
string)(
$source[
'content'] ??
''), $sourceLng, $targetLng,
'content'));
2106 foreach (array(
'seo_title',
'img_alt_1',
'img_alt_2',
'img_alt_3',
'img_des_1',
'img_des_2',
'img_des_3') as
$field) {
2108 $max =
$field ===
'seo_title' || strpos(
$field,
'img_alt_') === 0 ? 254 : 0;
2109 $data[
$field] = $this->clean(dbxContentTranslate::translate((
string)(
$source[
$field] ??
''), $sourceLng, $targetLng,
$field), $max);
2112 $data[
'permalink'] = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder($targetLng), $targetFolder, $data[
'title']);
2113 $data[
'lng_uid'] = $uid;
2114 $data[
'lng_sync'] =
'auto';
2115 $data[
'lng_rev'] = is_array(
$target) ? max(1, (
int)(
$target[
'lng_rev'] ?? 0) + 1) : 0;
2116 $data[
'lng_synced_rev'] = max(1, (
int)(
$source[
'lng_rev'] ?? 1));
2118 if ($targetId > 0) {
2119 if ($this->db->update($targetDd, $data, $targetId) !== 1) {
2120 throw new \RuntimeException(
'Zielseite konnte nicht aktualisiert werden.');
2122 $status =
'updated';
2124 if ($this->db->insert($targetDd, $data) !== 1) {
2125 throw new \RuntimeException(
'Zielseite konnte nicht erstellt werden.');
2127 $targetId = (int)$this->db->get_insert_id();
2128 $status =
'created';
2133 if ($replaceMediaUsage) {
2134 $this->db->update(
'dbxMediaUsage', array(
'active' => 0), dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $targetId .
' AND active = 1', $targetLng), 0, 1, 1, 1);
2135 $mediaCopied = $this->copy_media_usage($sourceId, $targetId, $targetFolder, $sourceLng, $targetLng);
2137 $mediaCopied = $this->copy_missing_media_usage($sourceId, $targetId, $targetFolder, $sourceLng, $targetLng);
2141 $row = $this->db->select1($targetDd, $targetId);
2142 $this->invalidate_page($targetId, $targetLng, $row);
2143 return array(
'status' => $status,
'entity' =>
'page',
'source_id' => $sourceId,
'target_lng' => $targetLng,
'target_id' => $targetId,
'title' => $data[
'title'],
'media_copied' => $mediaCopied);
2146 private function target_languages(array $params,
string $sourceLng): array {
2147 $raw = $params[
'target_lngs'] ?? $params[
'target_lng'] ?? array();
2148 if (is_string($raw)) {
2149 $raw = array_values(array_filter(array_map(
'trim', explode(
',', $raw))));
2150 } elseif (!is_array($raw)) {
2154 $raw = dbxContentLngSync::accessibleLngs();
2158 foreach ($raw as
$lng) {
2160 if (
$lng === $sourceLng || in_array(
$lng,
$out,
true)) {
2168 private function collect_folder_ids_for_lng(
string $lng,
int $rootFolderId = 0): array {
2169 $dd = dbxContentLng::ddFolder(
$lng);
2170 if ($rootFolderId <= 0) {
2171 $rows = $this->db->select(
$dd,
'',
'id',
'parent_id,sorter,id',
'ASC',
'', 0, 0, 0);
2172 return $this->ids_from_rows(
$rows);
2177 $queue = array($rootFolderId);
2178 while (count($queue)) {
2179 $id = (int)array_shift($queue);
2180 if ($id <= 0 || isset($seen[$id])) {
2185 $rows = $this->db->select(
$dd,
'parent_id = ' . $id,
'id',
'sorter,id',
'ASC',
'', 0, 0, 0);
2186 foreach ($this->ids_from_rows(
$rows) as $childId) {
2187 if (!isset($seen[$childId])) {
2188 $queue[] = $childId;
2195 private function collect_page_ids_for_lng(
string $lng,
int $rootFolderId, array $folderIds): array {
2196 $dd = dbxContentLng::ddContent(
$lng);
2197 if ($rootFolderId <= 0) {
2198 $rows = $this->db->select(
$dd,
'',
'id',
'folder,sorter,id',
'ASC',
'', 0, 0, 0);
2199 return $this->ids_from_rows(
$rows);
2202 $folderIds = array_values(array_filter(array_map(
'intval', $folderIds),
static function($id) {
2205 if (!count($folderIds)) {
2208 $rows = $this->db->select(
$dd,
'folder IN (' . implode(
',', $folderIds) .
')',
'id',
'folder,sorter,id',
'ASC',
'', 0, 0, 0);
2209 return $this->ids_from_rows(
$rows);
2212 private function ids_from_rows(
$rows): array {
2214 foreach (is_array(
$rows) ?
$rows : array() as $row) {
2215 if (is_array($row) && (
int)($row[
'id'] ?? 0) > 0) {
2216 $out[] = (int)$row[
'id'];
2222 private function target_folder_id_from_source_parent(
string $sourceLng,
string $targetLng,
int $sourceFolderId): int {
2223 $sourceFolderId = (int)$sourceFolderId;
2224 if ($sourceFolderId <= 0) {
2227 $sourceDd = dbxContentLng::ddFolder($sourceLng);
2228 $source = $this->db->select1($sourceDd, $sourceFolderId);
2233 $uid = dbxContentLngSync::ensureRecordUid($this->db, $sourceDd, $sourceFolderId,
'f');
2237 $targetDd = dbxContentLng::ddFolder($targetLng);
2238 $targetId = dbxContentLngSync::resolveIdByUid($this->db, $targetDd, $uid, $targetLng);
2239 if ($targetId > 0) {
2244 $created = $this->sync_translate_folder($sourceLng, $targetLng, $sourceFolderId,
true,
false);
2245 return (
int)(
$created[
'target_id'] ?? 0);
2246 }
catch (\Throwable $e) {
2251 private function copy_page_structure(array
$source): array {
2252 $skip = array(
'id',
'create_date',
'create_uid',
'update_date',
'update_uid',
'owner',
'title',
'permalink',
'description',
'keywords',
'content',
'lng_uid',
'lng_sync',
'lng_rev',
'lng_synced_rev');
2255 if (!in_array($key, $skip,
true)) {
2262 private function copy_folder_structure(array
$source): array {
2263 $skip = array(
'id',
'create_date',
'create_uid',
'update_date',
'update_uid',
'owner',
'name',
'parent_id',
'lng_uid',
'lng_sync',
'lng_rev',
'lng_synced_rev');
2266 if (!in_array($key, $skip,
true)) {
2273 private function copy_missing_media_usage(
int $sourceId,
int $targetId,
int $targetFolder,
string $sourceLng,
string $targetLng): int {
2274 $rows = $this->db->select(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
"content_id = " . $sourceId .
" AND active = 1 AND slot IN ('hero','gallery','inline','header','teaser','footer')", $sourceLng),
'*',
'slot,sorter,id',
'ASC',
'', 0, 0, 0);
2276 foreach (is_array(
$rows) ?
$rows : array() as $row) {
2277 if (!is_array($row)) {
2280 $mediaId = (int)($row[
'media_id'] ?? 0);
2281 $slot = str_replace(
"'",
"''", (
string)($row[
'slot'] ??
''));
2282 if (
$mediaId <= 0 || $slot ===
'') {
2285 if ($slot ===
'hero' && (
int)$this->db->count(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $targetId .
" AND slot = 'hero' AND active = 1", $targetLng)) > 0) {
2288 $exists = (int)$this->db->count(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $targetId .
' AND media_id = ' .
$mediaId .
" AND slot = '" . $slot .
"' AND active = 1", $targetLng));
2292 $data = $this->whitelist($row, array(
'media_id',
'slot',
'sorter',
'template',
'caption',
'settings'));
2293 $data[
'active'] = 1;
2294 $data[
'content_id'] = $targetId;
2295 $data[
'folder_id'] = $targetFolder;
2296 $data[
'content_lng'] = dbxContentMediaUsageScope::language($targetLng);
2297 if ($this->db->insert(
'dbxMediaUsage', $data) === 1) {
2304 private function folder_data(array $params,
int $parent,
string $name): array {
2307 'parent_id' => $parent,
2308 'group_read' => $this->clean($params[
'group_read'] ?? ($parent > 0 ?
'parent' :
'*'), 512),
2309 'template' => $this->clean($params[
'template'] ?? ($parent > 0 ?
'parent' :
'c-content'), 254),
2310 'hero_template' => $this->clean($params[
'hero_template'] ?? ($parent > 0 ?
'parent' :
'image-hero'), 80),
2311 'hero_image_id' => $this->clean($params[
'hero_image_id'] ??
'parent', 32),
2312 'hero_margin_top' => $this->clean($params[
'hero_margin_top'] ??
'parent', 32),
2313 'hero_height' => $this->clean($params[
'hero_height'] ?? ($parent > 0 ?
'parent' :
'300px'), 32),
2314 'hero_variant' => $this->clean($params[
'hero_variant'] ??
'parent', 32),
2315 'hero_sticky' => $this->clean($params[
'hero_sticky'] ??
'parent', 32),
2316 'hero_scroll_layer' => $this->clean($params[
'hero_scroll_layer'] ??
'parent', 32),
2320 private function page_data(array $params,
string $lng,
int $folder,
string $title): array {
2321 $permalink = trim($this->clean($params[
'permalink'] ??
'', 254));
2322 if ($permalink ===
'') {
2323 $permalink = dbxContent_permalink::build($this->db, dbxContentLng::ddFolder(
$lng), $folder, $title);
2325 if (!dbxContent_permalink::isValid($permalink)) {
2326 throw new \InvalidArgumentException(
'permalink darf nur Kleinbuchstaben, Zahlen und einzelne Bindestriche enthalten.');
2328 if (dbxContent_permalink::exists($this->db, dbxContentLng::ddContent(
$lng), $permalink)) {
2329 throw new \InvalidArgumentException(
'permalink wird bereits von einer anderen Seite verwendet.');
2333 'activ' => $this->bool_value($params[
'activ'] ??
true) ? 1 : 0,
2334 'folder' => $folder,
2336 'menu_title' => $this->clean($params[
'menu_title'] ??
'', 96),
2337 'seo_title' => $this->clean($params[
'seo_title'] ?? $title, 254),
2338 'permalink' => $permalink,
2339 'description' => $this->clean($params[
'description'] ??
'', 254),
2340 'keywords' => $this->clean($params[
'keywords'] ??
'', 254),
2341 'group_read' => $this->clean($params[
'group_read'] ??
'parent', 512),
2342 'template' => $this->clean($params[
'template'] ??
'parent', 254),
2343 'hero_template' => $this->clean($params[
'hero_template'] ??
'parent', 80),
2344 'hero_image_id' => $this->clean($params[
'hero_image_id'] ??
'parent', 32),
2345 'hero_margin_top' => $this->clean($params[
'hero_margin_top'] ??
'parent', 32),
2346 'hero_height' => $this->clean($params[
'hero_height'] ??
'300px', 32),
2347 'hero_variant' => $this->clean($params[
'hero_variant'] ??
'parent', 32),
2348 'hero_sticky' => $this->clean($params[
'hero_sticky'] ??
'parent', 32),
2349 'hero_scroll_layer' => $this->clean($params[
'hero_scroll_layer'] ??
'parent', 32),
2350 'gallery_template' => $this->clean($params[
'gallery_template'] ??
'image-gallery', 80),
2351 'gallery_visible_count' => $this->clean($params[
'gallery_visible_count'] ??
'3', 32),
2352 'gallery_image_size' => $this->clean($params[
'gallery_image_size'] ??
'original', 32),
2353 'gallery_lightbox_width' => $this->clean($params[
'gallery_lightbox_width'] ??
'100vw', 32),
2354 'gallery_overflow' => $this->clean($params[
'gallery_overflow'] ??
'grid', 32),
2355 'gallery_click_behavior' => $this->clean($params[
'gallery_click_behavior'] ??
'lightbox', 32),
2356 'sorter' => $this->clean($params[
'sorter'] ??
'', 32),
2357 'content' => $this->normalize_content_inline_media_urls((
string)($params[
'content'] ??
'')),
2368 private function assert_no_fake_inline_hero(
string $html): void {
2369 if (stripos(
$html,
'<img') === false || stripos(
$html,
'position') === false) {
2373 $doc = new \DOMDocument(
'1.0',
'UTF-8');
2374 $previous = libxml_use_internal_errors(
true);
2376 $loaded = $doc->loadHTML(
2377 '<div data-dbx-ki-content-root="1">' .
$html .
'</div>',
2378 LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
2381 libxml_clear_errors();
2382 libxml_use_internal_errors($previous);
2388 $xpath = new \DOMXPath($doc);
2389 $roots = $xpath->query(
'//*[@data-dbx-ki-content-root="1"]');
2390 $root = $roots instanceof \DOMNodeList ? $roots->item(0) :
null;
2391 if (!
$root instanceof \DOMElement) {
2395 foreach (
$root->childNodes as $child) {
2396 if ($child instanceof \DOMElement) {
2401 if (!
$first instanceof \DOMElement) {
2405 $images =
$first->getElementsByTagName(
'img');
2406 foreach ($images as $image) {
2407 $host = $image->parentNode;
2408 while ($host instanceof \DOMElement && $host !==
$root) {
2409 $class =
' ' . strtolower($host->getAttribute(
'class')) .
' ';
2410 $style = strtolower($host->getAttribute(
'style'));
2411 $relative = str_contains(
$class,
' position-relative ')
2412 || preg_match(
'/position\s*:\s*relative/i',
$style) === 1;
2414 foreach ($host->getElementsByTagName(
'*') as $candidate) {
2415 if ($candidate === $image || !$candidate instanceof \DOMElement) {
2418 $candidateClass =
' ' . strtolower($candidate->getAttribute(
'class')) .
' ';
2419 $candidateStyle = strtolower($candidate->getAttribute(
'style'));
2420 $absolute = str_contains($candidateClass,
' position-absolute ')
2421 || preg_match(
'/position\s*:\s*absolute/i', $candidateStyle) === 1;
2422 $text = trim(preg_replace(
'/\s+/u',
' ', $candidate->textContent ??
'') ??
'');
2423 $structuredText = $candidate->getElementsByTagName(
'h1')->length
2424 + $candidate->getElementsByTagName(
'h2')->length
2425 + $candidate->getElementsByTagName(
'p')->length
2426 + $candidate->getElementsByTagName(
'a')->length;
2427 if ($absolute && (mb_strlen($text) >= 80 || $structuredText >= 2)) {
2428 throw new \InvalidArgumentException(
2429 'dbxKi: Ein Bild mit ueberlagertem Text am Seitenanfang ist ein CMS-Hero. '
2430 .
'Hero-Bild ueber hero_image_id/media.assign slot=hero setzen und den Hero-Text '
2431 .
'vor den dbx:hero-Marker schreiben; kein Inline-Schein-Hero.'
2439 $host = $host->parentNode;
2444 private function patch(array $params): array {
2445 $patch = is_array($params[
'patch'] ?? null) ? $params[
'patch'] : $params;
2446 foreach (array(
'id',
'lng',
'patch',
'folder_id') as $key) {
2447 if ($key !==
'folder_id') unset($patch[$key]);
2452 private function whitelist(array $data, array
$allowed): array {
2453 return array_intersect_key($data, array_flip(
$allowed));
2456 private function lng_fields(
string $prefix,
string $lng): array {
2458 'lng_uid' => dbxContentLngSync::newUid($prefix),
2459 'lng_sync' =>
$lng === dbxContentLngSync::masterLng() ?
'auto' :
'manual',
2461 'lng_synced_rev' => 0,
2465 private function advance_revision(
string $dd,
int $id, array $data,
string $lng): array {
2466 $row = $this->db->select1(
$dd, $id,
'lng_uid,lng_rev', 0);
2467 $uid = trim((
string)($row[
'lng_uid'] ??
''));
2468 if ($uid ===
'') $uid = dbxContentLngSync::newUid(strpos(
$dd,
'folder') !==
false ?
'f' :
'p');
2469 $data[
'lng_uid'] = $uid;
2470 $data[
'lng_rev'] = max(1, (
int)($row[
'lng_rev'] ?? 0)) + 1;
2471 if (
$lng !== dbxContentLngSync::masterLng()) $data[
'lng_sync'] =
'manual';
2475 private function next_sorter(
string $dd,
string $field,
int $parent): string {
2476 $rows = $this->db->select(
$dd,
$field .
' = ' . $parent,
'sorter,id',
'sorter DESC,id DESC',
'ASC',
'', 1, 0, 0);
2477 $max = is_array(
$rows) && isset(
$rows[0]) ? (int)(
$rows[0][
'sorter'] ?? 0) : 0;
2478 return sprintf(
'%04d', $max + 10);
2481 private function next_usage_sorter(
int $content,
int $folder,
string $slot,
string $lng =
''): string {
2482 $where =
"active = 1 AND slot = '" . str_replace(
"'",
"''", $slot) .
"'";
2484 if ($folder > 0) $where .=
' AND folder_id = ' . $folder;
2485 $where = dbxContentMediaUsageScope::withLanguage($where,
$lng);
2486 $rows = $this->db->select(
'dbxMediaUsage', $where,
'sorter,id',
'sorter DESC,id DESC',
'ASC',
'', 1, 0, 0);
2487 $max = is_array(
$rows) && isset(
$rows[0]) ? (int)(
$rows[0][
'sorter'] ?? 0) : 0;
2488 return sprintf(
'%04d', $max + 10);
2491 private function folder_descendant(
string $dd,
int $candidate,
int $ancestor): bool {
2493 while ($candidate > 0 && !isset($seen[$candidate])) {
2494 if ($candidate === $ancestor)
return true;
2495 $seen[$candidate] = 1;
2496 $row = $this->db->select1(
$dd, $candidate,
'parent_id', 0);
2497 if (!is_array($row))
break;
2498 $candidate = (int)($row[
'parent_id'] ?? 0);
2503 private function slot(
$value): string {
2504 $slot = strtolower(trim((string)
$value));
2505 $allowed = array(
'hero',
'gallery',
'inline',
'header',
'teaser',
'footer');
2506 if (!in_array($slot,
$allowed,
true))
throw new \InvalidArgumentException(
'Ungültiger Medienslot: ' . $slot);
2510 private function safe_file_name(
$value): string {
2511 $name = basename(str_replace(
'\\',
'/', trim((string)
$value)));
2512 $name = preg_replace(
'/[^A-Za-z0-9._-]+/',
'-', $name);
2513 return trim((
string)$name,
'.-');
2516 private function decode_base64(
string $raw): string {
2518 if (preg_match(
'~^data:[^;]+;base64,(.*)$~s', $raw, $match)) $raw = $match[1];
2519 $decoded = base64_decode(preg_replace(
'/\s+/',
'', $raw),
true);
2520 if ($decoded ===
false)
throw new \InvalidArgumentException(
'data_base64 ist ungültig.');
2524 private function detect_mime(
string $bytes,
string $name): string {
2525 if (class_exists(
'\finfo')) {
2526 $finfo = new \finfo(FILEINFO_MIME_TYPE);
2527 $mime = (string)$finfo->buffer($bytes);
2528 if ($mime !==
'')
return $mime;
2530 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
2531 $map = array(
'jpg' =>
'image/jpeg',
'jpeg' =>
'image/jpeg',
'png' =>
'image/png',
'webp' =>
'image/webp',
'gif' =>
'image/gif',
'pdf' =>
'application/pdf',
'txt' =>
'text/plain');
2532 return $map[$ext] ??
'application/octet-stream';
2535 private function resolve_local_file(
string $path): string {
2536 $path = trim(str_replace(
'\\',
'/', $path));
2537 if ($path ===
'')
return '';
2538 if (!preg_match(
'~^(?:[A-Za-z]:/|/)~', $path)) {
2539 $path = rtrim(str_replace(
'\\',
'/',
dbx()->
get_base_dir()),
'/') .
'/' . ltrim($path,
'/');
2541 return dbx()->os_path($path);
2544 private function media_local_file(array
$media): string {
2545 if ((
$media[
'storage_type'] ??
'local') !==
'local') return
'';
2546 $filePath = trim((
string)(
$media[
'file_path'] ??
''));
2547 if ($filePath ===
'')
return '';
2548 $filePath = preg_replace(
'~^files/~',
'', str_replace(
'\\',
'/', $filePath));
2549 if (strpos($filePath,
'media/') !== 0)
return '';
2553 private function hero_media_for_page(
string $lng,
int $id): array {
2554 $page = $this->db->select1(dbxContentLng::ddContent(
$lng), $id);
2555 if (!is_array(
$page))
throw new \RuntimeException(
'Seite nicht gefunden.');
2560 $rows = $this->db->select(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $id .
" AND slot = 'hero' AND active = 1",
$lng),
'*',
'sorter,id',
'DESC',
'', 1, 0, 0);
2561 if (is_array(
$rows) && is_array(
$rows[0] ??
null)) {
2563 $mediaId = (int)($usage[
'media_id'] ?? 0);
2566 if (
$mediaId <= 0)
throw new \RuntimeException(
'Die Seite hat kein bestehendes Hero-Bild.');
2569 if (!is_array(
$media) || (
int)(
$media[
'active'] ?? 0) !== 1)
throw new \RuntimeException(
'Hero-Medium nicht gefunden.');
2571 $rows = $this->db->select(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $id .
' AND media_id = ' .
$mediaId .
" AND slot = 'hero' AND active = 1",
$lng),
'*',
'sorter,id',
'DESC',
'', 1, 0, 0);
2574 return array(
'page' =>
$page,
'media' =>
$media,
'usage' => $usage);
2577 private function source_image_plan(array $params): array {
2578 $source = $this->resolve_local_file((string)($params[
'source_file'] ??
''));
2580 throw new \InvalidArgumentException(
'source_file ist nicht lesbar.');
2582 $info = @getimagesize(
$source);
2583 if (!is_array($info) || empty($info[0]) || empty($info[1])) {
2584 throw new \InvalidArgumentException(
'source_file ist kein lesbares Bild.');
2586 $mime = (string)($info[
'mime'] ??
'');
2587 if (!in_array($mime, array(
'image/jpeg',
'image/png',
'image/webp',
'image/gif'),
true)) {
2588 throw new \InvalidArgumentException(
'Nicht unterstützter Quellbildtyp: ' . $mime);
2592 'sha256' => hash_file(
'sha256',
$source),
2594 'width' => (
int)$info[0],
2595 'height' => (
int)$info[1],
2596 'crop' => $this->image_crop_rect($params, (
int)$info[0], (
int)$info[1]),
2597 'tint' => $this->normalize_hex_color((
string)($params[
'tint'] ??
'')),
2598 'tint_strength' => max(0.0, min(1.0, (
float)($params[
'tint_strength'] ?? 0))),
2602 private function image_fit(
$value): string {
2603 $fit = strtolower(trim((string)
$value));
2604 return in_array($fit, array(
'cover',
'contain'),
true) ? $fit :
'cover';
2607 private function image_quality(
$value): int {
2608 return min(100, max(1, (int)
$value));
2611 private function image_crop_rect(array $params,
int $sourceWidth,
int $sourceHeight): array {
2612 $sourceWidth = max(1, $sourceWidth);
2613 $sourceHeight = max(1, $sourceHeight);
2614 $x = (int)($params[
'crop_x'] ?? 0);
2615 $y = (int)($params[
'crop_y'] ?? 0);
2616 $width = (int)($params[
'crop_width'] ?? $sourceWidth);
2617 $height = (int)($params[
'crop_height'] ?? $sourceHeight);
2619 $x = max(0, min($x, $sourceWidth - 1));
2620 $y = max(0, min($y, $sourceHeight - 1));
2621 $width = max(1, min($width, $sourceWidth - $x));
2622 $height = max(1, min($height, $sourceHeight - $y));
2624 return array(
'x' => $x,
'y' => $y,
'width' => $width,
'height' => $height);
2627 private function mime_from_file_name(
string $name): string {
2628 $ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
2629 $map = array(
'jpg' =>
'image/jpeg',
'jpeg' =>
'image/jpeg',
'png' =>
'image/png',
'webp' =>
'image/webp');
2630 return $map[$ext] ??
'image/webp';
2633 private function normalize_hex_color(
string $value): string {
2635 if (
$value ===
'')
return '';
2637 return preg_match(
'/^#[0-9A-Fa-f]{6}$/',
$value) ? strtoupper(
$value) :
'';
2640 private function gd_load_image(
string $file,
string $mime) {
2643 $image = @imagecreatefromjpeg(
$file);
2646 $image = @imagecreatefrompng(
$file);
2649 $image = function_exists(
'imagecreatefromwebp') ? @imagecreatefromwebp(
$file) : false;
2652 $image = @imagecreatefromgif(
$file);
2657 if (!$image)
throw new \RuntimeException(
'Bild konnte nicht geladen werden.');
2658 imagealphablending($image,
true);
2659 imagesavealpha($image,
true);
2663 private function render_image_variant_to_file(array
$source,
string $file,
int $width,
int $height,
string $fit,
string $mime,
int $quality): void {
2664 if (!hash_equals((string)(
$source[
'sha256'] ??
''), hash_file(
'sha256', (string)
$source[
'file']))) {
2665 throw new \RuntimeException(
'Das Quellbild stimmt nicht mehr mit dem geprüften Plan überein.');
2667 $src = $this->gd_load_image((
string)
$source[
'file'], (
string)
$source[
'mime']);
2668 $dst = imagecreatetruecolor($width, $height);
2669 imagealphablending($dst,
false);
2670 imagesavealpha($dst,
true);
2671 $transparent = imagecolorallocatealpha($dst, 255, 255, 255, 127);
2672 imagefilledrectangle($dst, 0, 0, $width, $height, $transparent);
2674 $sourceWidth = imagesx($src);
2675 $sourceHeight = imagesy($src);
2678 $crop = is_array(
$source[
'crop'] ??
null) ?
$source[
'crop'] : array();
2680 $sourceX = max(0, min((
int)($crop[
'x'] ?? 0), $sourceWidth - 1));
2681 $sourceY = max(0, min((
int)($crop[
'y'] ?? 0), $sourceHeight - 1));
2682 $sourceWidth = max(1, min((
int)($crop[
'width'] ?? $sourceWidth), imagesx($src) - $sourceX));
2683 $sourceHeight = max(1, min((
int)($crop[
'height'] ?? $sourceHeight), imagesy($src) - $sourceY));
2685 if ($fit ===
'contain') {
2686 $scale = min($width / $sourceWidth, $height / $sourceHeight);
2687 $copyWidth = max(1, (
int)round($sourceWidth * $scale));
2688 $copyHeight = max(1, (
int)round($sourceHeight * $scale));
2689 imagecopyresampled($dst, $src, (
int)floor(($width - $copyWidth) / 2), (
int)floor(($height - $copyHeight) / 2), $sourceX, $sourceY, $copyWidth, $copyHeight, $sourceWidth, $sourceHeight);
2691 $sourceRatio = $sourceWidth / $sourceHeight;
2692 $targetRatio = $width / $height;
2693 if ($sourceRatio > $targetRatio) {
2694 $cropHeight = $sourceHeight;
2695 $cropWidth = (int)round($sourceHeight * $targetRatio);
2696 $srcX = $sourceX + (int)floor(($sourceWidth - $cropWidth) / 2);
2699 $cropWidth = $sourceWidth;
2700 $cropHeight = (int)round($sourceWidth / $targetRatio);
2702 $srcY = $sourceY + (int)floor(($sourceHeight - $cropHeight) / 2);
2704 imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $width, $height, $cropWidth, $cropHeight);
2707 $this->gd_apply_tint($dst, (
string)(
$source[
'tint'] ??
''), (
float)(
$source[
'tint_strength'] ?? 0));
2708 $this->gd_save_image($dst,
$file, $mime, $quality);
2712 private function gd_apply_tint($image,
string $hex,
float $strength): void {
2713 if ($hex ===
'' || $strength <= 0) return;
2714 $r = hexdec(substr($hex, 1, 2));
2715 $g = hexdec(substr($hex, 3, 2));
2716 $b = hexdec(substr($hex, 5, 2));
2717 $overlay = imagecreatetruecolor(imagesx($image), imagesy($image));
2718 imagealphablending($overlay,
false);
2719 imagesavealpha($overlay,
true);
2720 $color = imagecolorallocate($overlay, $r, $g, $b);
2721 imagefilledrectangle($overlay, 0, 0, imagesx($overlay), imagesy($overlay), $color);
2722 imagecopymerge($image, $overlay, 0, 0, 0, 0, imagesx($image), imagesy($image), (
int)round($strength * 100));
2723 imagedestroy($overlay);
2726 private function gd_save_image($image,
string $file,
string $mime,
int $quality): void {
2728 if ($mime ===
'image/webp' && function_exists(
'imagewebp')) {
2729 $ok = @imagewebp($image,
$file, $quality);
2730 } elseif ($mime ===
'image/png') {
2731 $compression = (int)round((100 - $quality) / 100 * 9);
2732 $ok = @imagepng($image,
$file, max(0, min(9, $compression)));
2733 } elseif ($mime ===
'image/jpeg') {
2734 $white = imagecreatetruecolor(imagesx($image), imagesy($image));
2735 $bg = imagecolorallocate($white, 255, 255, 255);
2736 imagefilledrectangle($white, 0, 0, imagesx($white), imagesy($white), $bg);
2737 imagecopy($white, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
2738 $ok = @imagejpeg($white,
$file, $quality);
2739 imagedestroy($white);
2741 if (!
$ok)
throw new \RuntimeException(
'Bildvariante konnte nicht gespeichert werden.');
2744 private function media_folder(
$value,
string $type): string {
2745 $folder = trim(str_replace(
'\\',
'/', (string)
$value),
'/');
2746 $folder = preg_replace(
'~[^A-Za-z0-9/_-]+~',
'-', $folder);
2747 if ($type ===
'video') {
2750 $root = $type ===
'image' ?
'img' :
'file';
2751 if ($folder ===
'' || ($folder !==
$root && strpos($folder,
$root .
'/') !== 0)) $folder = $type ===
'image' ?
'img/images' :
'file/ki';
2755 private function unique_name(
string $dir,
string $name): string {
2756 $base = pathinfo($name, PATHINFO_FILENAME);
2757 $ext = pathinfo($name, PATHINFO_EXTENSION);
2760 while (is_file(rtrim(
$dir,
'/\\') . DIRECTORY_SEPARATOR . $candidate)) {
2761 $candidate =
$base .
'-' . $i++ . ($ext !==
'' ?
'.' . $ext :
'');
2766 private function invalidate_page(
int $id,
string $lng, array $row): void {
2767 dbxContentPageCache::invalidateContent($id);
2768 dbxContentPageCache::invalidateAllMenus();
2769 $previousLng =
dbx()->get_system_var(
'dbx_lng', dbxContentLngSync::masterLng());
2770 dbx()->set_system_var(
'dbx_lng',
$lng);
2772 $rights =
$renderer->getPublicFolderRights((
int)($row[
'folder'] ?? 0));
2773 if ((
int)($row[
'activ'] ?? 1) === 1 && trim((
string)($row[
'permalink'] ??
'')) !==
'') {
2774 dbxContentPermalinkIndex::upsertPage($id, (
string)$row[
'permalink'], $rights, 1,
$lng);
2776 dbxContentPermalinkIndex::removeByCid($id,
$lng);
2778 dbxContentHome::refreshHomeCache($this->db, $id,
$lng);
2779 dbx()->set_system_var(
'dbx_lng', $previousLng);
2782 private function invalidate_folder(
int $id): void {
2783 dbxContentPageCache::invalidateFolderTree($this->db, $id);
2784 dbxContentPageCache::invalidateAllMenus();
2787 private function invalidate_usage(array $usage): void {
2788 $content = (int)($usage[
'content_id'] ?? 0);
2789 $folder = (int)($usage[
'folder_id'] ?? 0);
2791 if ($folder > 0) dbxContentPageCache::invalidateFolderTree($this->db, $folder);
2792 dbxContentPageCache::invalidateAllMenus();
2795 private function invalidate_media_references(
int $mediaId): void {
2801 $rows = $this->db->select(
'dbxMediaUsage',
'media_id = ' .
$mediaId .
' AND active = 1',
'*',
'id',
'ASC',
'', 0, 0, 0);
2802 foreach (is_array(
$rows) ?
$rows : array() as $row) {
2803 if (is_array($row)) {
2804 $this->invalidate_usage($row);
2809 private function copy_media_usage(
int $sourceId,
int $targetId,
int $targetFolder,
string $sourceLng,
string $targetLng): int {
2810 $rows = $this->db->select(
'dbxMediaUsage', dbxContentMediaUsageScope::withLanguage(
"content_id = " . $sourceId .
" AND active = 1 AND slot IN ('hero','gallery','inline','header','teaser','footer')", $sourceLng),
'*',
'slot,sorter,id',
'ASC',
'', 0, 0, 0);
2812 foreach (is_array(
$rows) ?
$rows : array() as $row) {
2813 $data = $this->whitelist($row, array(
'media_id',
'slot',
'sorter',
'template',
'caption',
'settings'));
2814 $data[
'active'] = 1;
2815 $data[
'content_id'] = $targetId;
2816 $data[
'folder_id'] = $targetFolder;
2817 $data[
'content_lng'] = dbxContentMediaUsageScope::language($targetLng);
2818 if ($this->db->insert(
'dbxMediaUsage', $data) === 1)
$count++;
2823 private function inline_media_src(
int $id): string {
2824 return
'index.php?dbx_modul=dbxContent&dbx_run1=media&dbx_mid=' . max(0, (int)$id);
2827 private function package_media_file_map(): array {
2829 'dbxapp-paket-demo' =>
'paket-demo-360x480.webp',
2830 'dbxapp-paket-non-profit' =>
'paket-nonprofit-360x480.webp',
2831 'dbxapp-paket-business' =>
'paket-business-360x480.webp',
2832 'dbxapp-paket-intranet' =>
'paket-intranet-360x480.webp',
2833 'dbxapp-paket-enterprise' =>
'paket-enterprise-360x480.webp',
2837 private function package_media_id_for_permalink(
string $permalink): int {
2838 $permalink = trim(strtolower($permalink));
2839 $map = $this->package_media_file_map();
2840 $fileName = (string)($map[$permalink] ??
'');
2841 if ($fileName ===
'') {
2844 $where =
"active = 1 AND file_name = '" . str_replace(
"'",
"''", $fileName) .
"'";
2845 $row = $this->db->select1(
'dbxMedia', $where);
2846 return is_array($row) ? (int)($row[
'id'] ?? 0) : 0;
2849 private function package_page_hint(array
$page): ?array {
2850 $permalink = trim((string)(
$page[
'permalink'] ??
''));
2851 $mediaId = $this->package_media_id_for_permalink($permalink);
2856 'permalink' => $permalink,
2858 'file_name' => (
string)($this->package_media_file_map()[strtolower($permalink)] ??
''),
2859 'inline_src' => $this->inline_media_src(
$mediaId),
2860 'update_patch' => array(
'package_product_image' =>
true),
2864 private function apply_package_product_image(
string $content,
int $mediaId,
string $alt =
''): string {
2868 $srcEsc = htmlspecialchars($this->inline_media_src(
$mediaId), ENT_QUOTES,
'UTF-8');
2869 $altEsc = htmlspecialchars($alt !==
'' ? $alt :
'Paket', ENT_QUOTES,
'UTF-8');
2870 $img =
'<img class="card-img-top" src="' . $srcEsc .
'" data-cms-media-id="' .
$mediaId .
'" alt="' . $altEsc .
'">';
2873 '/<div class="col-md-4"><div class="card shadow-sm(?:\s+position-relative)?">(?:<img[^>]*card-img-top[^>]*>)?(?:<span class="position-absolute[^>]*>[\s\S]*?<\/span>)?<div class="card-body text-center">([\s\S]*?)<\/div><\/div><\/div>/i',
2874 function($m) use ($img) {
2875 $body = (string)($m[1] ??
'');
2877 if (preg_match(
'/<span class="badge[^>]*bg-success[^>]*>([\s\S]*?)<\/span>/i',
$body, $badgeMatch)) {
2878 $label = trim(strip_tags((
string)($badgeMatch[1] ??
'Kostenlos')));
2879 if ($label !==
'') {
2880 $badge =
'<span class="position-absolute top-0 end-0 badge rounded-pill bg-success m-2">' . htmlspecialchars($label, ENT_QUOTES,
'UTF-8') .
'</span>';
2883 $body = preg_replace(
'/<span class="badge[^>]*>[\s\S]*?<\/span>\s*(?:<br\s*\/?>)?\s*/i',
'',
$body, 1);
2884 $body = preg_replace(
'/<img\b[^>]*>\s*/i',
'',
$body, 1);
2885 $body = preg_replace(
'/\bh5 mt-3\b/',
'h5',
$body, 1);
2886 return '<div class="col-md-4"><div class="card shadow-sm position-relative">' . $img . $badge .
'<div class="card-body text-center">' .
$body .
'</div></div></div>';
2895 private function ensure_inline_media_usage(
int $contentId,
int $mediaId,
string $lng =
''): void {
2896 $contentId = (int)$contentId;
2898 if ($contentId <= 0 ||
$mediaId <= 0) {
2901 $lng = dbxContentMediaUsageScope::language(
$lng);
2902 $where = dbxContentMediaUsageScope::withLanguage(
'content_id = ' . $contentId .
' AND media_id = ' .
$mediaId .
" AND slot = 'inline' AND active = 1",
$lng);
2903 if (is_array($this->db->select1(
'dbxMediaUsage', $where))) {
2909 'content_id' => $contentId,
2911 'content_lng' =>
$lng,
2916 'sorter' => $this->next_usage_sorter($contentId, 0,
'inline',
$lng),
2918 $this->db->insert(
'dbxMediaUsage', $data);
2921 private function media_inline_payload(
int $id): array {
2922 $id = max(0, (int)$id);
2926 $src = $this->inline_media_src($id);
2928 'inline_src' => $src,
2929 'inline_img' =>
'<img src="' . htmlspecialchars($src, ENT_QUOTES,
'UTF-8') .
'" data-cms-media-id="' . $id .
'" alt="">',
2933 private function normalize_content_inline_media_urls(
string $html): string {
2935 if (
$html ===
'' || stripos(
$html,
'<img') ===
false) {
2939 return preg_replace_callback(
'/<img\b([^>]*?)>/i',
function($m) {
2940 $tag = (string)($m[0] ??
'');
2941 $attrs = (string)($m[1] ??
'');
2943 if (preg_match(
'/\bdata-cms-media-id=["\']?([0-9]+)/i', $attrs, $id_match)) {
2944 $id = (int)$id_match[1];
2945 } elseif (preg_match(
'/\bdbx_mid=([0-9]+)/i', $attrs, $id_match)) {
2946 $id = (int)$id_match[1];
2947 } elseif (preg_match(
'/\bsrc=(["\'])([^"\']+)\1/i', $attrs, $src_match)) {
2948 $id = $this->media_id_by_inline_src((
string)$src_match[2]);
2953 return $this->patch_img_tag_for_inline_media($tag, $id);
2957 private function media_id_by_inline_src(
string $src): int {
2958 $src = html_entity_decode(trim($src), ENT_QUOTES,
'UTF-8');
2959 if ($src ===
'' || preg_match(
'#^(?:https?:)?//#i', $src) || stripos($src,
'dbx_mid=') !==
false) {
2963 $path = preg_replace(
'/[?#].*$/',
'', str_replace(
'\\',
'/', $src));
2965 if (preg_match(
'#(?:^|/)(?:files/)?media/(.+)$#i', $path, $match)) {
2966 $rel =
'media/' . ltrim((
string)$match[1],
'/');
2971 static $cache = array();
2972 if (isset($cache[$rel])) {
2973 return (
int)$cache[$rel];
2976 $where =
"active = 1 AND file_path = '" . str_replace(
"'",
"''", $rel) .
"'";
2977 $row = $this->db->select1(
'dbxMedia', $where);
2978 if (is_array($row) && (
int)($row[
'id'] ?? 0) > 0) {
2979 return $cache[$rel] = (int)$row[
'id'];
2982 $base = basename($rel);
2984 return $cache[$rel] = 0;
2986 $rows = $this->db->select(
2988 "active = 1 AND file_name = '" . str_replace(
"'",
"''",
$base) .
"'",
2997 if (is_array(
$rows)) {
2998 foreach (
$rows as $candidate) {
2999 $candidatePath = ltrim(str_replace(
'\\',
'/', (
string)($candidate[
'file_path'] ??
'')),
'/');
3000 if ($candidatePath === $rel || basename($candidatePath) ===
$base) {
3001 return $cache[$rel] = (int)($candidate[
'id'] ?? 0);
3006 return $cache[$rel] = 0;
3009 private function patch_img_tag_for_inline_media(
string $tag,
int $id): string {
3010 $id = max(0, (int)$id);
3014 $src = $this->inline_media_src($id);
3015 $src_attr = htmlspecialchars($src, ENT_QUOTES,
'UTF-8');
3016 $tag = preg_replace(
'/\s*data-cms-media-id\s*=\s*["\']?[^"\'>\s]*["\']*/i',
'', $tag);
3017 if (preg_match(
'/\bsrc=(["\'])([^"\']*)\1/i', $tag)) {
3018 $tag = preg_replace(
'/\bsrc=(["\'])([^"\']*)\1/i',
'src="' . $src_attr .
'"', $tag, 1);
3020 $tag = preg_replace(
'/^<img\b/i',
'<img src="' . $src_attr .
'"', $tag);
3022 $tag = preg_replace(
'/^<img\b/i',
'<img data-cms-media-id="' . $id .
'"', $tag);
3027 return $this->catalog();
3035 if (!empty(
$catalog[$action][
'destructive'])) {
3043 throw new \InvalidArgumentException(
'Aktion im Bundle nicht erlaubt: ' . $action);
3045 return $this->build_plan($action, $params);
3050 throw new \InvalidArgumentException(
'Aktion im Bundle nicht erlaubt: ' . $action);
3052 return $this->execute_action($action, $params,
$plan);
3064 return $this->snapshot($params);
3068 return $this->describe();